diff --git a/.agents/skills/v2-api-conventions/SKILL.md b/.agents/skills/v2-api-conventions/SKILL.md index a7174f61e30..0b77773666a 100644 --- a/.agents/skills/v2-api-conventions/SKILL.md +++ b/.agents/skills/v2-api-conventions/SKILL.md @@ -149,6 +149,16 @@ Order matters because each layer is checked against the one before it. 3. **Route** with `defineV2JsonRoute`, declaring `contract`, `auth: v2ApiKeyAuth`, `operation`, `rateLimit`, `errorPolicy`, `mapInput`, `useCase`, `present`. Auth and rate limiting run before parsing. 4. **OpenAPI description** in `lib/api/contracts/v2/openapi/.ts`, then `bun run generate:openapi`. A description that claims behaviour the route does not have is the same class of bug as a wrong schema. +### Public descriptions + +Use the [API description conventions](../../../apps/sim/lib/api/contracts/v2/openapi/README.md) when writing or auditing endpoint and field descriptions. Keep a short action-and-resource summary; use the description for behavior that changes the caller's choice, input, interpretation, or next action. Ordinary operations usually need one to three sentences, with no mandatory minimum. + +Keep archive versus permanent-delete behavior, replacement versus partial-update semantics, partial success, retry safety, redaction, and asynchronous completion explicit. Verify these claims against the implementation. Describe observable behavior without exposing storage formats, locking mechanisms, internal identifiers, deployment architecture, or implementation history unless that detail changes how the caller must use the API. + +Reuse wording across resource families when behavior matches: “Omitted fields remain unchanged,” “Archive,” and “permanently delete.” Prefer “during the request” or “asynchronously” to “settled inline.” Preserve real semantic differences; do not standardize them away. + +Put field-specific rules in the source schema and reuse shared authentication and pagination wording. Shared schema descriptions also feed CLI help, so refer to related operation names rather than HTTP paths. Regenerate OpenAPI, CLI metadata, and CLI docs after changing their source descriptions; never hand-edit generated output. + ## Rule 6 — a transient failure says when to come back A response the caller is *expected* to retry must say how long to wait. Two statuses qualify, and both are wired: @@ -186,11 +196,14 @@ Audited against the primary specs and against Stripe, GitHub, and Google's AIPs. ## Idempotency: at-most-once, not replay -`POST /workflows/{id}/execute` accepts `X-Run-Id`, a caller-supplied run identifier claimed through the `idempotency_key` table (`execution-id-claim.ts`). It is a **uniqueness claim, not an idempotency key**, and the distinction is deliberate and already published in the operation description: +`POST /workflows/{id}/execute` accepts `X-Run-Id` from API-key and OAuth callers; anonymous requests ignore it. It is a **uniqueness claim, not an idempotency key**: + +- An available ID is claimed before execution starts. +- An already claimed ID returns **409** with `error.details.code: "RUN_ID_CONFLICT"`, the run id in `error.details.runId`, and an `X-Run-Id` response header. It never replays the earlier run's result. Get Workflow Run can retrieve an existing run, but a claim does not guarantee a retrievable run. +- IDs of runs that started remain reserved after their execution logs are deleted. +- An ambiguous enqueue can retain the claim indefinitely without creating a retrievable run. A **409** followed by **404** is an unresolved outcome, not proof that execution never started or that the ID will become reusable. -- First use wins and runs. -- Any reuse returns **409** with `error.details.code: "RUN_ID_CONFLICT"`, the run id in `error.details.runId`, and an `X-Run-Id` response header. It never replays the earlier run's result — the client recovers it by polling the runs resource. -- Claims are durable tombstones, so deleting execution logs cannot make an id reusable. +For an uncertain execution outcome, reuse the same run ID if retrying and check Get Workflow Run. Do not promise polling will eventually find a run. If the outcome cannot be verified, do not automatically restart with a fresh ID or an omitted header: either can start another execution. Failures before a run starts can release the claim, so phrase the conflict rule as an ID that is already claimed. That makes the money path safe against double-execution **for callers that opt in**. What it is not: a Stripe-style `Idempotency-Key` that stores and replays the original status and body. Building that means a request fingerprint, a retention window, an in-flight-vs-completed distinction (the expired IETF draft would have these be 422 and 409 respectively), and somewhere to put a large synchronous execution body. It is a designed piece of work, not an increment — do not half-build it by aliasing the header name, which would invite clients written against Stripe semantics to treat our 409 as a hard failure. diff --git a/.claude/rules/emcn-components.md b/.claude/rules/emcn-components.md index f4a88a40314..50e7684363f 100644 --- a/.claude/rules/emcn-components.md +++ b/.claude/rules/emcn-components.md @@ -32,6 +32,7 @@ The menu surface intentionally diverges from the pill: `dropdown-menu.tsx` items - **`ChipDatePicker`** — chip-styled date field. - **`ChipTimePicker`** — minute-granular time sibling of `ChipDatePicker`, a `ChipInput` that leniently parses typed input (`9:47`, `947`, `2:05pm`, `14:30`), commits on Enter/blur, and re-renders the canonical `9:47 AM` label. - **`DropdownMenu`** — the canonical context/action menu (Radix-backed). Not a chip, but the standard menu for command/action lists; reach for it instead of a hand-rolled popover. Its surface intentionally diverges from the chip pill (`text-small`, `gap-2`) — keep them distinct. For a pill that opens a value picker, use `ChipDropdown`/`ChipSelect` instead. +- **`useScrollEdges` + `scrollFadeClass` / `scrollFadeAttributes`** — the canonical scroll-region edge treatment. The hook reports which edges hide content (tracking scroll and resizes; pass the element itself, held in state, when the region mounts after its owner, e.g. inside a Radix portal); the class and attributes fade a fixed 12px band at an active edge only, so a list that fits or sits at its top is never fogged. A floating control over the top edge sets `--scroll-fade-inset` to its height. A region that scrolls sideways (a tab row, a chip strip) uses `useScrollEdges(ref, { axis: 'x' })` with `scrollFadeXClass`; the attributes helper is shared. Any divider beside the region belongs to the neighboring block (`border-b` above, `border-t` below), never to the masked element, and shows only while that edge is active. Never hand-roll a `mask-image` gradient for a scroll region. - **`OverflowText`** — the canonical single-line overflow treatment for read-only human labels and titles. It owns `min-w-0`, fade-only clipping (never an ellipsis), the conditional 18px edge mask, and the full-value floating tooltip; consumers pass only layout/typography through `className`. `overflowTextClipClass` and `overflowTextFadeClass` are the complete base/faded treatments for the rare component that must own measurement itself; never pair either with `truncate`, `text-ellipsis`, or hover-time mask removal. Use `DropdownMenuItemLabel` for a menu label beside icons, checks, or actions. A non-editable `Combobox` passes the full visual value through `overlayLabel`; the combobox owns the visual overlay's fade and keeps its one accessible tooltip on the interactive layer. Keep ordinary `truncate` only for editable values, code/log/path content, dense or virtualized grids, and rich composite content that cannot supply a plain tooltip label. Multiline copy uses an intentional `line-clamp-*` treatment instead. ## Modal keyboard defaults diff --git a/.claude/rules/sim-styling.md b/.claude/rules/sim-styling.md index 51a882f8607..61960c9eb5c 100644 --- a/.claude/rules/sim-styling.md +++ b/.claude/rules/sim-styling.md @@ -60,6 +60,10 @@ Use `DropdownMenuItemLabel` for a human label beside menu icons, checks, shortcu Do not apply the fade universally to editable or mirrored input values, code, logs, paths, filenames that use intentional middle truncation, dense or virtualized grids, or a composite container that also holds icons/actions. Those keep their purpose-built overflow behavior. Multiline copy uses an intentional `line-clamp-*` treatment. +## Scroll Edges + +A scroll region that can hide rows past an edge uses `useScrollEdges` with `scrollFadeClass` + `scrollFadeAttributes` from `@sim/emcn`: a 12px fade at an edge only while content is hidden beyond it, never at rest. The region's baseline padding lives on the scroll box itself (so rows pass through it under the fade), and the divider at that edge is drawn by the neighboring block, conditional on the same edge. Never hand-roll a `mask-image` gradient or a `scrollTop > 0` effect for this. + ## Font Weight Three steps, Tailwind's stock scale, nothing else: **`font-normal` (400)**, **`font-medium` (500)**, **`font-semibold` (600)**. 400 is the document default, so body text, chip labels, sidebar items, and headings carry **no weight class at all** — they inherit. Reach for a class only to step *up* from body. @@ -70,7 +74,7 @@ Headings inherit their weight. Tailwind preflight resets `h1`–`h6` to `font-we ## Color Tokens -Value text `--text-body`; muted/placeholder/labels `--text-muted`; icons `--text-icon`; neutral borders and dividers `--border` (`--border-1` and `--border-muted` are legacy aliases resolving to it; `--divider` is retired); surfaces `--surface-5` (light) / `--surface-4` (dark); active row `--surface-active`; error `--text-error`. No focus rings on chip surfaces. +Value text `--text-body`; muted/placeholder/labels `--text-muted`; icons `--text-icon`; progress and completion (a checked step, a done state) `--brand-blue` — `--selection` stays the interactive highlight; neutral borders and dividers `--border` (`--border-1` and `--border-muted` are legacy aliases resolving to it; `--divider` is retired); surfaces `--surface-5` (light) / `--surface-4` (dark); active row `--surface-active`; error `--text-error`. No focus rings on chip surfaces. ### Line weight diff --git a/.github/workflows/helm.yml b/.github/workflows/helm.yml index ac6298ed8d6..6c2e29f79fe 100644 --- a/.github/workflows/helm.yml +++ b/.github/workflows/helm.yml @@ -5,6 +5,8 @@ on: branches: [main, staging, dev] paths: - 'helm/sim/**' + # Repository-level Artifact Hub metadata, republished by the publish job. + - 'helm/artifacthub-repo.yml' - '.github/workflows/helm.yml' # The image inventory is generated from the chart and checked here, so a # change to its generator has to run this workflow too. @@ -14,6 +16,8 @@ on: branches: [main, staging, dev] paths: - 'helm/sim/**' + # Repository-level Artifact Hub metadata, republished by the publish job. + - 'helm/artifacthub-repo.yml' - '.github/workflows/helm.yml' # The image inventory is generated from the chart and checked here, so a # change to its generator has to run this workflow too. @@ -34,6 +38,8 @@ jobs: timeout-minutes: 15 steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false - name: Set up Helm uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4 @@ -125,11 +131,16 @@ jobs: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: fetch-depth: 0 + # The version gate only reads history and fetches a public branch, so + # it never needs the token left behind in .git/config. + persist-credentials: false - name: Require a Chart.yaml version bump when chart content changes + env: + BASE_REF: ${{ github.base_ref }} run: | set -euo pipefail - base="origin/${{ github.base_ref }}" - git fetch origin "${{ github.base_ref }}" + base="origin/${BASE_REF}" + git fetch origin "${BASE_REF}" merge_base=$(git merge-base "$base" HEAD) changed=$(git diff --name-only "$merge_base" HEAD) if echo "$changed" | grep -q '^helm/sim/'; then @@ -151,6 +162,8 @@ jobs: timeout-minutes: 25 steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false - name: Set up Helm uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4 @@ -181,3 +194,291 @@ jobs: - name: Run helm test run: helm test sim --namespace sim --timeout 5m + + # Publishes the chart to GHCR as an OCI artifact. Self-hosters cannot admit a + # chart pulled from a git checkout — they need an immutable, versioned artifact + # they can pin by digest and mirror into an internal registry — so shipping the + # chart in-repo only is the same as not shipping it. + # + # Lives here rather than in a `publish-*.yml` of its own so it can gate on the + # jobs above: nothing is published unless the chart linted, unit-tested, + # rendered clean under kubeconform, and actually installed on a kind cluster. + # A separate workflow would race those instead of waiting for them. + publish: + name: Publish chart to GHCR + needs: [chart, install] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' && github.repository == 'simstudioai/sim' + runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }} + timeout-minutes: 15 + permissions: + contents: read # Read the chart source. + packages: write # Push the chart, its signature, and its attestations to GHCR. + id-token: write # Sigstore signs against the runner's OIDC identity; no key material is stored. + attestations: write # Let actions/attest-build-provenance record the SLSA provenance. + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + + - name: Set up Helm + uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4 + with: + version: v3.16.4 + + # oras also reads ~/.docker/config.json, so this one login covers both the + # chart push and the Artifact Hub metadata push below. + - name: Login to GHCR + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Package chart + id: package + run: | + set -euo pipefail + chart=$(helm show chart helm/sim) + name=$(printf '%s\n' "$chart" | awk '/^name:/ {print $2}') + version=$(printf '%s\n' "$chart" | awk '/^version:/ {print $2}') + helm package helm/sim --destination dist + { + echo "name=${name}" + echo "version=${version}" + echo "path=dist/${name}-${version}.tgz" + echo "repository=ghcr.io/${GITHUB_REPOSITORY_OWNER}/charts/${name}" + } >> "$GITHUB_OUTPUT" + + # `appVersion` is what the image tags default to, so a stale one publishes + # a chart that silently installs an old Sim -- and because published chart + # versions are immutable, every stale value is frozen forever. It sat six + # releases behind before this check existed, bumped only by hand. + # + # BEHIND is the failure. AHEAD is normal and must not be blocked: a + # version tag is cut by the main-branch merge commit that releases it + # (detect-version in ci.yml), so appVersion legitimately names a release + # that does not exist yet while that release is still being built. Failing + # on any mismatch would race that workflow and block the very publish the + # bump was for. `helm/sim/ci/kind-values.yaml` documents the same + # circularity, and it is why appVersion went unbumped for so long. + # + # Compares against the latest GitHub release rather than a hardcoded value + # so the check cannot go stale itself. Prereleases and drafts are excluded: + # the `/releases/latest` endpoint already returns neither. + - name: appVersion does not lag the app release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + app_version=$(helm show chart helm/sim | awk '/^appVersion:/ {print $2}' | tr -d '"') + latest=$(gh api "repos/${GITHUB_REPOSITORY}/releases/latest" --jq .tag_name) + if [ -z "$latest" ]; then + echo "::error::Could not resolve the latest release; refusing to publish unverified." + exit 1 + fi + if [ "$app_version" = "$latest" ]; then + echo "appVersion ${app_version} matches the latest release." + exit 0 + fi + oldest=$(printf '%s\n%s\n' "$app_version" "$latest" | sort -V | head -1) + if [ "$oldest" = "$app_version" ]; then + echo "::error::Chart.yaml appVersion is ${app_version} but the latest release is ${latest}. Bump appVersion (and the chart version) so the chart does not publish an install pinned to an older Sim." + exit 1 + fi + echo "::notice::appVersion ${app_version} is ahead of the latest release ${latest}, which is expected while that release is still being cut." + + # Chart versions are immutable once published: whoever pinned a version + # must keep resolving the same bytes forever. The PR gate above already + # forces a version bump on every chart change, so a version that is + # already in the registry means this commit changed something outside + # `helm/sim/`. + # + # The lookup must fail CLOSED. Treating every non-zero exit as "absent" + # would let a transient 5xx, an expired token, or a DNS blip re-push an + # existing version and move a tag consumers have already pinned — and + # same-version runs are routine, since the path filter also fires on + # `package.json` and workflow edits. + # + # Verified against the pinned Helm (v3.16.4): an absent version AND an + # absent repository both report `: not found`, so a first publish + # still proceeds, while `denied`, `unauthorized`, and `dial tcp` failures + # do not match and stop the job instead. + - name: Skip if this version is already published + id: exists + env: + REPOSITORY: ${{ steps.package.outputs.repository }} + NAME: ${{ steps.package.outputs.name }} + VERSION: ${{ steps.package.outputs.version }} + run: | + set -euo pipefail + if err=$(helm show chart "oci://${REPOSITORY}" --version "${VERSION}" 2>&1 >/dev/null); then + echo "already=true" >> "$GITHUB_OUTPUT" + echo "::notice::${NAME} ${VERSION} is already published; skipping." + elif printf '%s\n' "$err" | grep -q ': not found'; then + echo "already=false" >> "$GITHUB_OUTPUT" + else + printf '%s\n' "$err" + echo "::error::Could not determine whether ${NAME} ${VERSION} is already published. Refusing to push, because an unchecked push can overwrite a published version." + exit 1 + fi + + # `helm push` takes the namespace only — it derives the repository + # basename from the chart's name and the tag from its version, so the + # result is ghcr.io//charts/sim:. + - name: Push chart + id: push + if: steps.exists.outputs.already == 'false' + env: + CHART_PATH: ${{ steps.package.outputs.path }} + run: | + set -euo pipefail + output=$(helm push "${CHART_PATH}" "oci://ghcr.io/${GITHUB_REPOSITORY_OWNER}/charts" 2>&1) + printf '%s\n' "$output" + digest=$(printf '%s\n' "$output" | grep -oE 'sha256:[a-f0-9]{64}' | head -1 || true) + if [ -z "$digest" ]; then + echo "::error::helm push did not report a digest; refusing to sign an unidentified artifact" + exit 1 + fi + echo "digest=${digest}" >> "$GITHUB_OUTPUT" + + - name: Install Cosign + if: steps.exists.outputs.already == 'false' + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + + # Signed by digest, never by tag: a tag is a mutable pointer, so signing + # one would attest to whatever it happens to reference later. The verify + # is not ceremony — it fails the run if the signature we just wrote cannot + # be read back with the identity we expect, which is the whole point of + # publishing a signature at all. + - name: Sign and verify chart + if: steps.exists.outputs.already == 'false' + env: + REPOSITORY: ${{ steps.package.outputs.repository }} + DIGEST: ${{ steps.push.outputs.digest }} + run: | + set -euo pipefail + ref="${REPOSITORY}@${DIGEST}" + cosign sign --yes "$ref" + cosign verify "$ref" \ + --certificate-identity-regexp "^https://github.com/${GITHUB_REPOSITORY}/" \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com + + # Stored alongside the chart so a mirrored registry carries the + # attestation with it, rather than only being retrievable from GitHub. + - name: Attest build provenance + if: steps.exists.outputs.already == 'false' + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-name: ${{ steps.package.outputs.repository }} + subject-digest: ${{ steps.push.outputs.digest }} + push-to-registry: true + + - name: Set up ORAS + uses: oras-project/setup-oras@1d808f7d7f6995cc68b7bf507bfe5c5446e1dc9d # v2.0.1 + + # Artifact Hub reads repository metadata from the reserved `artifacthub.io` + # tag on the chart's own OCI repository. Pushed on every run, including + # version-skip runs, so an edit to the metadata file alone still lands. + - name: Publish Artifact Hub metadata + env: + REPOSITORY: ${{ steps.package.outputs.repository }} + # Run from `helm/` so the layer's title annotation is the bare + # `artifacthub-repo.yml`, matching Artifact Hub's documented command. A + # path-qualified argument records `helm/artifacthub-repo.yml` instead. + working-directory: helm + run: | + set -euo pipefail + oras push "${REPOSITORY}:artifacthub.io" \ + --config /dev/null:application/vnd.cncf.artifacthub.config.v1+yaml \ + artifacthub-repo.yml:application/vnd.cncf.artifacthub.repository-metadata.layer.v1.yaml + + - name: Summary + env: + ALREADY: ${{ steps.exists.outputs.already }} + REPOSITORY: ${{ steps.package.outputs.repository }} + VERSION: ${{ steps.package.outputs.version }} + DIGEST: ${{ steps.push.outputs.digest }} + run: | + { + if [ "${ALREADY}" = "true" ]; then + echo "### Chart ${VERSION} was already published — nothing to do" + else + echo "### Published chart ${VERSION}" + echo + echo "Digest: \`${DIGEST}\`" + fi + echo + echo '```bash' + echo "helm install sim oci://${REPOSITORY} --version ${VERSION}" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + + # The classic HTTP repo, published alongside the OCI artifact above. Both is + # what the ecosystem actually does: Bitnami, cert-manager, ingress-nginx, + # prometheus-community, Grafana, Argo and external-secrets all still serve an + # index.yaml, because plenty of clusters, GitOps configs and mirroring tools + # only speak `helm repo add`. OCI is the modern path, not yet the only one. + # + # Separate from the OCI job on purpose: chart-releaser needs `contents: write` + # to cut a release and push the index, and there is no reason to hand that to + # the job holding the signing identity. + publish-http: + name: Publish chart to the Helm repo + needs: [chart, install] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' && github.repository == 'simstudioai/sim' + runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }} + timeout-minutes: 15 + permissions: + contents: write # Cut the chart release and push index.yaml to the pages branch. + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + # chart-releaser diffs against the previous tag to decide which charts + # changed, so it needs the full history rather than a shallow clone. + fetch-depth: 0 + # chart-releaser authenticates with CR_TOKEN, not the checkout credential. + persist-credentials: false + + # Creating the pages branch and turning on GitHub Pages are one-time + # manual steps that no workflow can do for itself. Skip loudly rather than + # failing main when they have not happened yet -- the OCI publish is + # independent and must not be held hostage to this. + - name: Check the pages branch exists + id: pages + run: | + set -euo pipefail + if git ls-remote --exit-code --heads origin gh-pages >/dev/null 2>&1; then + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + echo "::warning::No gh-pages branch, so the HTTP chart repo was not updated. Create it and point GitHub Pages at it to activate this job. The OCI publish is unaffected." + fi + + - name: Configure Git + if: steps.pages.outputs.exists == 'true' + env: + ACTOR: ${{ github.actor }} + run: | + set -euo pipefail + git config user.name "${ACTOR}" + git config user.email "${ACTOR}@users.noreply.github.com" + + # chart-releaser writes index.yaml to the pages branch and attaches the + # .tgz to a GitHub release, which is where index.yaml points -- so the + # packages stay reachable no matter which domain serves the index. + - name: Run chart-releaser + if: steps.pages.outputs.exists == 'true' + uses: helm/chart-releaser-action@cae68fefc6b5f367a0275617c9f83181ba54714f # v1.7.0 + with: + charts_dir: helm + # Re-running on an already-released version must be a no-op, the same + # way the OCI publish above refuses to move a published version. + skip_existing: true + # A chart release must never take the "Latest" badge from the + # application release it packages. + mark_as_latest: false + env: + CR_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Keeps chart releases visually distinct from the vX.Y.Z app releases + # they share the list with. + CR_RELEASE_NAME_TEMPLATE: "helm-chart-{{ .Version }}" diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index c5028b877c1..99e64b97f2b 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -8,6 +8,151 @@ permissions: contents: read jobs: + oauth-postgres: + name: PostgreSQL integration (${{ matrix.provision }}) + runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-latest' }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + provision: [push, migrate] + services: + postgres: + image: pgvector/pgvector:pg17 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: sim_auth_scim + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres -d sim_auth_scim" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + env: + DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_auth_scim + OAUTH_TOKEN_FAMILY_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_auth_scim + BETTER_AUTH_SECRET: oauth-postgres-ci-secret-at-least-32-characters + NEXT_PUBLIC_APP_URL: https://test.sim.ai + ENCRYPTION_KEY: '0000000000000000000000000000000000000000000000000000000000000000' + + steps: + - name: Checkout code + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.4.1 + + - name: Setup Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: 24 + + - name: Mount Bun cache + uses: ./.github/actions/cache-mount + with: + provider: ${{ vars.CI_PROVIDER }} + key: ${{ github.repository }}-bun-cache-${{ github.event_name }}${{ github.event.pull_request.head.repo.fork && '-fork' || '' }} + path: ~/.bun/install/cache + + - name: Install dependencies + run: bun install --frozen-lockfile --ignore-scripts + + - name: Provision a fresh database through the supported command + working-directory: packages/db + run: | + bun -e 'import postgres from "postgres"; const sql = postgres(process.env.DATABASE_URL); for (const extension of ["vector", "btree_gin", "pg_trgm"]) await sql`CREATE EXTENSION IF NOT EXISTS ${sql(extension)}`; await sql.end()' + bun run db:${{ matrix.provision }} + + - name: Verify migration replay is a no-op + if: matrix.provision == 'migrate' + working-directory: packages/db + run: bun run db:migrate + + - name: Verify OAuth lifecycle and SCIM membership guards in PostgreSQL + working-directory: apps/sim + run: >- + bunx vitest run + lib/auth/oauth-token-family.postgres.test.ts + lib/auth/oauth-provider-lifecycle.postgres.test.ts + app/api/auth/oauth2/token/route.postgres.test.ts + lib/auth/sim-auth-adapter.test.ts + ee/scim/lib/managed-membership.postgres.test.ts + lib/auth/sso/application/admit-sso-user.postgres.test.ts + + - name: Verify SCIM and administration over real HTTP + working-directory: apps/sim + env: + NEXT_PUBLIC_APP_URL: http://127.0.0.1:3017 + BETTER_AUTH_URL: http://127.0.0.1:3017 + NEXT_PUBLIC_FORCE_HOSTED: 'true' + BILLING_ENABLED: 'true' + NEXT_PUBLIC_BILLING_ENABLED: 'true' + ENTERPRISE_ENABLED: 'true' + NEXT_PUBLIC_ENTERPRISE_ENABLED: 'true' + SCIM_ENABLED: 'true' + NEXT_PUBLIC_SCIM_ENABLED: 'true' + SSO_ENABLED: 'true' + NEXT_PUBLIC_SSO_ENABLED: 'true' + ORGANIZATIONS_ENABLED: 'true' + NEXT_PUBLIC_ORGANIZATIONS_ENABLED: 'true' + INTERNAL_API_SECRET: scim-http-ci-local-secret-at-least-32-characters + DB_TX_TRIPWIRE: throw + DISABLE_TELEMETRY: 'true' + NEXT_TELEMETRY_DISABLED: '1' + NEXT_PUBLIC_CHAT_DISABLED: 'true' + run: | + server_log="$RUNNER_TEMP/scim-next.log" + node ../../node_modules/next/dist/bin/next dev --hostname 127.0.0.1 --port 3017 > "$server_log" 2>&1 & + server_pid=$! + finish() { + kill "$server_pid" 2>/dev/null || true + wait "$server_pid" 2>/dev/null || true + awk '/^ (GET|POST|PUT|PATCH|DELETE|HEAD) \/api\// { print }' "$server_log" > "$RUNNER_TEMP/scim-http-status.log" + } + trap finish EXIT + deadline=$((SECONDS + 120)) + until curl --fail --silent --max-time 3 http://127.0.0.1:3017/api/health > /dev/null; do + if ! kill -0 "$server_pid" 2>/dev/null; then + echo 'Local SCIM app exited during startup.' + exit 1 + fi + if [ "$SECONDS" -ge "$deadline" ]; then + echo 'Local SCIM app did not become ready within 120 seconds.' + exit 1 + fi + sleep 2 + done + SCIM_E2E_BASE_URL="$NEXT_PUBLIC_APP_URL" \ + SCIM_E2E_DATABASE_URL="$DATABASE_URL" \ + SCIM_E2E_AUTH_SECRET="$BETTER_AUTH_SECRET" \ + SCIM_E2E_REPORT_PATH="$RUNNER_TEMP/scim-e2e-report.json" \ + bun run test:scim:e2e + + - name: Upload SCIM failure report and HTTP status log + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: scim-failure-${{ matrix.provision }} + path: | + ${{ runner.temp }}/scim-e2e-report.json + ${{ runner.temp }}/scim-http-status.log + if-no-files-found: ignore + retention-days: 7 + + - name: Verify durable provenance bindings and concurrent memory writes + working-directory: apps/sim + env: + TABLE_PROVENANCE_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_auth_scim + MEMORY_PROVENANCE_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/sim_auth_scim + run: >- + bunx vitest run + lib/table/rows/secret-provenance.postgres.test.ts + lib/memory/message-provenance.postgres.test.ts + test-build: name: Lint and Test runs-on: ${{ (vars.CI_PROVIDER == '' || vars.CI_PROVIDER == 'blacksmith') && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-latest' }} diff --git a/apps/desktop/e2e/smoke.spec.ts b/apps/desktop/e2e/smoke.spec.ts index 4b6c03016ca..df17dd4f51f 100644 --- a/apps/desktop/e2e/smoke.spec.ts +++ b/apps/desktop/e2e/smoke.spec.ts @@ -10,7 +10,7 @@ import { _electron as electron, expect, test } from '@playwright/test' const DESKTOP_DIR = fileURLToPath(new URL('..', import.meta.url)) const PAGES: Record = { - '/workspace': `Sim Fixture + '/home': `Sim Fixture

fixture-app

@@ -82,7 +82,7 @@ test.describe('desktop shell smoke', () => { app = await launchApp(origin) const window = await app.firstWindow() await expect(window.locator('#app')).toHaveText('fixture-app') - expect(window.url()).toBe(`${origin}/workspace`) + expect(window.url()).toBe(`${origin}/home`) }) test('internal window.open creates an independent full Sim window', async () => { @@ -150,7 +150,7 @@ test.describe('desktop shell smoke', () => { app.evaluate(() => (globalThis as { __openedExternal?: string[] }).__openedExternal) ) .toEqual(['https://docs.sim.ai/navigation']) - expect(window.url()).toBe(`${origin}/workspace`) + expect(window.url()).toBe(`${origin}/home`) }) test('unreachable origin shows the bundled offline page', async () => { diff --git a/apps/desktop/src/main/app-routes.test.ts b/apps/desktop/src/main/app-routes.test.ts index 245019bdc51..6816c745736 100644 --- a/apps/desktop/src/main/app-routes.test.ts +++ b/apps/desktop/src/main/app-routes.test.ts @@ -5,15 +5,15 @@ describe('app routes', () => { it('derives the new-chat route from the last workspace route', () => { expect(newChatRoute('/workspace/ws1/w/wf2')).toBe('/workspace/ws1/home') expect(newChatRoute('/workspace/ws1/home?resource=r1')).toBe('/workspace/ws1/home') - expect(newChatRoute('/account')).toBe('/workspace') - expect(newChatRoute(undefined)).toBe('/workspace') - expect(newChatRoute('//evil.example')).toBe('/workspace') + expect(newChatRoute('/account')).toBe('/home') + expect(newChatRoute(undefined)).toBe('/home') + expect(newChatRoute('//evil.example')).toBe('/home') }) it('derives the settings route from the last workspace route', () => { expect(settingsRoute('/workspace/ws1/w/wf2')).toBe('/workspace/ws1/settings/desktop') - expect(settingsRoute('/account')).toBe('/workspace') - expect(settingsRoute(undefined)).toBe('/workspace') - expect(settingsRoute('//evil.example')).toBe('/workspace') + expect(settingsRoute('/account')).toBe('/home') + expect(settingsRoute(undefined)).toBe('/home') + expect(settingsRoute('//evil.example')).toBe('/home') }) }) diff --git a/apps/desktop/src/main/app-routes.ts b/apps/desktop/src/main/app-routes.ts index 6e877edd013..abbf62bba33 100644 --- a/apps/desktop/src/main/app-routes.ts +++ b/apps/desktop/src/main/app-routes.ts @@ -10,6 +10,12 @@ import { isSafeInternalPath } from '@/main/config' * do with the tray, and the tray can be absent entirely. */ +/** + * The web app's signed-in entry. It resolves to the organization the user belongs + * to, or to their workspaces, so the shell never has to know which applies. + */ +export const APP_ENTRY_ROUTE = '/home' + /** Workspace id from the last visited route, or null when it carries none. */ function workspaceIdFromRoute(lastRoute: string | undefined): string | null { if (isSafeInternalPath(lastRoute)) { @@ -23,19 +29,19 @@ function workspaceIdFromRoute(lastRoute: string | undefined): string | null { /** * Route for "New Chat": the home (chat) surface of the workspace the user was - * last in, falling back to the workspace picker redirect when the last route - * carries no workspace. + * last in, falling back to the app entry when the last route carries no + * workspace. */ export function newChatRoute(lastRoute: string | undefined): string { const workspaceId = workspaceIdFromRoute(lastRoute) - return workspaceId ? `/workspace/${workspaceId}/home` : '/workspace' + return workspaceId ? `/workspace/${workspaceId}/home` : APP_ENTRY_ROUTE } /** * Route for "Settings…": the Sim app's settings surface for the workspace the - * user was last in, falling back to the workspace picker redirect. + * user was last in, falling back to the app entry. */ export function settingsRoute(lastRoute: string | undefined): string { const workspaceId = workspaceIdFromRoute(lastRoute) - return workspaceId ? `/workspace/${workspaceId}/settings/desktop` : '/workspace' + return workspaceId ? `/workspace/${workspaceId}/settings/desktop` : APP_ENTRY_ROUTE } diff --git a/apps/desktop/src/main/session-lifecycle.test.ts b/apps/desktop/src/main/session-lifecycle.test.ts index b359110bf90..bf441fe3f9f 100644 --- a/apps/desktop/src/main/session-lifecycle.test.ts +++ b/apps/desktop/src/main/session-lifecycle.test.ts @@ -75,9 +75,9 @@ describe('decideStartRoute', () => { }) it('falls back to /workspace for missing, unsafe, or auth-surface last routes', () => { - expect(decideStartRoute(undefined)).toBe('/workspace') - expect(decideStartRoute('//evil.example')).toBe('/workspace') - expect(decideStartRoute('/login')).toBe('/workspace') + expect(decideStartRoute(undefined)).toBe('/home') + expect(decideStartRoute('//evil.example')).toBe('/home') + expect(decideStartRoute('/login')).toBe('/home') }) }) @@ -94,11 +94,11 @@ describe('resolveStartRoute', () => { ) }) - it('falls back to the workspace picker after confirmed access denial', async () => { + it('falls back to the app entry after confirmed access denial', async () => { const session = sessionWithResponse(403, { error: 'Workspace access denied' }) await expect(resolveStartRoute(session, APP, '/workspace/revoked/chat/c1')).resolves.toBe( - '/workspace' + '/home' ) }) diff --git a/apps/desktop/src/main/session-lifecycle.ts b/apps/desktop/src/main/session-lifecycle.ts index c6d47899b2b..5a8eee85932 100644 --- a/apps/desktop/src/main/session-lifecycle.ts +++ b/apps/desktop/src/main/session-lifecycle.ts @@ -7,6 +7,7 @@ import { completeAccountDataTeardown, waitForAccountDataMutations, } from '@/main/account-data-generation' +import { APP_ENTRY_ROUTE } from '@/main/app-routes' import { isSafeInternalPath } from '@/main/config' import { isAuthSurfacePath, openExternalSafe } from '@/main/navigation' import type { EventRecorder } from '@/main/observability' @@ -60,14 +61,14 @@ export function isLogoutNavigation(rawUrl: string, appOrigin: string): boolean { /** * Picks the route to load at launch: the last visited route (when safe and - * not itself an auth surface), falling back to /workspace. A signed-out + * not itself an auth surface), falling back to the app entry. A signed-out * partition is handled by the web app's own login redirect. */ export function decideStartRoute(lastRoute: string | undefined): string { if (lastRoute && isSafeInternalPath(lastRoute) && !isAuthSurfacePath(lastRoute)) { return lastRoute } - return '/workspace' + return APP_ENTRY_ROUTE } function workspaceIdFromRoute(route: string): string | null { @@ -110,8 +111,8 @@ export async function resolveStartRoute( } ) if (response.status === 403) { - logger.info('Saved workspace route is no longer accessible; opening workspace picker') - return '/workspace' + logger.info('Saved workspace route is no longer accessible; opening the app entry') + return APP_ENTRY_ROUTE } return route } catch { diff --git a/apps/docs/content/docs/api-reference/authentication.mdx b/apps/docs/content/docs/api-reference/authentication.mdx index 6b8c50fef50..fbef2a325e7 100644 --- a/apps/docs/content/docs/api-reference/authentication.mdx +++ b/apps/docs/content/docs/api-reference/authentication.mdx @@ -1,12 +1,14 @@ --- title: Authentication -description: API key types, generation, and how to authenticate requests +description: Authenticate with API keys or delegated OAuth access tokens --- import { Callout } from 'fumadocs-ui/components/callout' import { Tab, Tabs } from 'fumadocs-ui/components/tabs' -To access the Sim API, you need an API key. Sim supports two types of API keys — **personal keys** and **workspace keys** — each with different billing and access behaviors. +The Sim API accepts API keys and, when enabled by your deployment, OAuth access tokens. API keys support automation and the SDKs. OAuth lets the CLI and registered applications act on your behalf with permissions you approve. + +Sim supports two types of API keys — **personal keys** and **workspace keys** — each with different billing and access behaviors. ## Key Types @@ -87,6 +89,27 @@ API keys authenticate access to: - **MCP servers** — authenticate connections to deployed MCP servers - **SDKs** — the [Python](/api-reference/python) and [TypeScript](/api-reference/typescript) SDKs use API keys for all operations +## OAuth access tokens + +Use `sim login` to authorize the CLI in your browser, or `sim login --read-only` to request read access. The CLI stores the login locally and refreshes access tokens automatically. See [CLI authentication](/cli/authentication) for profiles, sign-in, and sign-out. + +Registered OAuth applications send access tokens in the `Authorization` header: + +```bash +curl https://www.sim.ai/api/v2/workspaces \ + -H "Authorization: Bearer YOUR_ACCESS_TOKEN" +``` + +| Scope | Access | +| --- | --- | +| `api:read` | Read operations, including searches sent as POST requests | +| `api:write` | Includes `api:read`, plus mutations and execution, including operations that can start external work | +| `offline_access` | Refresh tokens for continued access after the access token expires | + +Scopes limit what an application may do; your current workspace membership and role still apply. Each endpoint documents its required scope. Some GET endpoints that perform external discovery require `api:write`, so HTTP method alone does not determine the permission. + +Manage grants in **Settings** → **General** → **Authorized apps**. Revoking an application signs out all of its logins. `sim logout` revokes the current CLI login and removes it from your machine. The Python and TypeScript SDKs currently use API keys; they do not manage OAuth sign-in or refresh tokens. + ## Security - Keys use the `sk-sim-` prefix and are encrypted at rest diff --git a/apps/docs/content/docs/api-reference/getting-started.mdx b/apps/docs/content/docs/api-reference/getting-started.mdx index db66a4fd9e6..ad4e5561b64 100644 --- a/apps/docs/content/docs/api-reference/getting-started.mdx +++ b/apps/docs/content/docs/api-reference/getting-started.mdx @@ -26,7 +26,7 @@ Download the [complete OpenAPI 3.1 specification](/openapi.json) as JSON for cli ### Get your API key -Go to the Sim platform and navigate to **Settings**, then go to **Sim Keys** and click **Create**. See [Authentication](/api-reference/authentication) for details on key types. +Open **Account settings** → **Sim API keys** to create a personal key, or **Workspace settings** → **Sim API keys** for a workspace key. These examples and the SDKs use API keys; the CLI also supports browser sign-in with `sim login`. See [Authentication](/api-reference/authentication) for key types and OAuth permissions. diff --git a/apps/docs/content/docs/cli/audit-logs.mdx b/apps/docs/content/docs/cli/audit-logs.mdx index 1c06a2785ee..a03c7a7c136 100644 --- a/apps/docs/content/docs/cli/audit-logs.mdx +++ b/apps/docs/content/docs/cli/audit-logs.mdx @@ -15,7 +15,7 @@ Every command below also accepts the [global options](/cli/commands#global-optio sim audit-logs get [options] ``` -Get Audit Log (personal API key required) +Get Audit Log (OAuth login or personal API key required) **Arguments** @@ -33,7 +33,7 @@ Get Audit Log (personal API key required) | Option | Required | Description | | --- | --- | --- | -| `--organization ` | No | Organization ID; defaults to your only organization, and is required when your account belongs to more than one (personal API key required). | +| `--organization ` | No | Organization ID; defaults to your only organization, and is required when your account belongs to more than one (OAuth login or personal API key required). | @@ -43,7 +43,7 @@ Get Audit Log (personal API key required) sim audit-logs list [options] ``` -List Audit Logs (personal API key required) +List Audit Logs (OAuth login or personal API key required) **Options** @@ -59,8 +59,9 @@ List Audit Logs (personal API key required) | `--include-departed` | No | Include actions by users who have left the organization. | | `--no-include-departed` | No | Send --include-departed as false. | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | -| `--organization ` | No | Organization ID; defaults to your only organization, and is required when your account belongs to more than one (personal API key required). | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | +| `--organization ` | No | Organization ID; defaults to your only organization, and is required when your account belongs to more than one (OAuth login or personal API key required). | | `--actor-email ` | No | Filter by actor email address. | -| `--all-workspaces` | No | Do not filter to the configured workspace (personal API key required for account-wide access). | +| `--all-workspaces` | No | Do not filter to the configured workspace (OAuth login or personal API key required for account-wide access). | diff --git a/apps/docs/content/docs/cli/authentication.mdx b/apps/docs/content/docs/cli/authentication.mdx index 68461afd01f..c56eccfa94a 100644 --- a/apps/docs/content/docs/cli/authentication.mdx +++ b/apps/docs/content/docs/cli/authentication.mdx @@ -5,8 +5,10 @@ description: Sign in from the terminal, authenticate CI with an API key, and kee import { Callout } from 'fumadocs-ui/components/callout' -The CLI authenticates with a Sim API key. `sim login` mints and stores one; in CI -you supply one through the environment instead. +`sim login` signs you in through your browser. It prefers OAuth, which stores a +short-lived login that renews itself, and selects API-key pairing for remote +terminals or servers without OAuth support. In CI you supply an existing API +key through the environment instead. ## Signing in @@ -14,9 +16,76 @@ you supply one through the environment instead. sim login ``` -The terminal prints a pairing code and a URL: +Choose a method explicitly when the credential type matters: +```bash +sim login --method oauth +sim login --method api-key +``` + +`--method oauth` requires a server with OAuth support and authentication enabled; +it never falls back to an API key. Explicit OAuth selection also overrides +SSH/headless detection; your browser still needs to reach the CLI's +local callback. `--method api-key` uses pairing-code approval to create a new +permanent API key. To supply an existing key, set `SIM_API_KEY` instead. + +API-key pairing requires a server that supports `platform` API keys. Upgrade +older deployments that only issue `copilot` keys before starting login; those +keys cannot authenticate the platform CLI. + +OAuth login opens your browser on Sim's sign-in page, then on a consent page that +names the Sim CLI and what it will be able to do. Approve, and the browser hands +control back to the terminal: + +``` +Signing in to https://www.sim.ai as profile default + +https://www.sim.ai/api/auth/oauth2/authorize?client_id=sim-cli&… + +Waiting for you to approve in the browser… + +✓ Logged in. Login stored in /Users/you/.sim/credentials + Renews itself; revoke it any time in Settings → General → Authorized apps, or with: sim logout + No default workspace. Set one with: sim configure --set-workspace ``` + +This is the OAuth 2.0 authorization-code flow with PKCE and a loopback redirect, +aligned with current OAuth security guidance. The browser only ever carries a one-time code; +the tokens are exchanged over the terminal's own connection and written to +`~/.sim/credentials` with `0600` permissions. Access tokens last an hour and are +renewed automatically from a refresh token. The complete login has a fixed +30-day lifetime; after it expires, run `sim logout`, then sign in again. + + +Only approve a consent page you reached by running `sim login` yourself. A +consent page that appears unprompted, or one you were sent a link to, is not +your login. + + +| Option | What it does | +| --- | --- | +| `--method ` | `oauth` requires OAuth login; `api-key` creates a permanent key through pairing. Auto-selects when omitted | +| `--no-browser` | Print the approval URL without opening it; works with either method | +| `--read-only` | Ask only for permission to read, never to change anything | +| `--callback-port ` | Pin the loopback callback port, primarily for an SSH session that forwards the same fixed port | +| `-y, --yes` | Overwrite an existing API-key profile without prompting | + +### Over SSH or in a container + +OAuth login needs your browser to reach a listener on the machine running +`sim`. When it cannot — an SSH session, a dev container, a remote box — use the +API-key pairing flow. The CLI selects it automatically in an SSH session when +no method or callback port is specified: + +```bash +sim login --method api-key --no-browser +``` + +The terminal prints a pairing code and a URL you can open on any device: + +``` +Signing in to https://www.sim.ai as profile default + Pairing code: K7M2-P9XT Confirm this code matches what the browser shows before approving. @@ -27,45 +96,52 @@ Waiting for approval… Personal key, defaulting to 2f6d0b1c-8a34-4d92-b7e5-31c8a0f45d67. Override per command with --workspace. ``` -There is no loopback listener, so this works over SSH and inside containers. - Confirm the pairing code in the browser matches the one in your terminal before approving. That check is what binds the approval to your terminal. -| Option | What it does | -| --- | --- | -| `--no-browser` | Print the URL instead of opening a browser | -| `--scope ` | Key space to mint from: `platform` (default) or `copilot` | -| `-y, --yes` | Overwrite an existing profile without prompting | +The handoff issues a permanent personal API key rather than a renewing login, +so revoke it under **Settings → API keys** when you are done with that machine. +It also works when OAuth is unavailable or switched off, provided the server +supports platform API-key pairing. When `--method` is omitted, the CLI checks +OAuth availability and selects pairing if unavailable; that discovery does not +verify pairing compatibility. An explicit `--method oauth` fails in that case. + +`--read-only` and `--callback-port` belong to OAuth login and have no +meaning here, so combining either with the handoff stops the login rather than +storing a credential you did not ask for. If your SSH session forwards a port +from the remote loopback interface to the browser's machine, use +`--method oauth --callback-port ` with that port. An ordinary container port publication +cannot reach a listener bound to the container's own loopback interface; use +`--method api-key` there. ### Picking a workspace -You choose the workspace on the approval page. `sim login` issues a **personal** key. The workspace you pick becomes the -profile's default `workspace`; it does **not** restrict the key to that -workspace. Target another workspace the key can reach with `--workspace`: +A normal login can act across every workspace you belong to; `--read-only` +limits it to read operations. The profile's `workspace` setting only decides the default target. +Set it after signing in, or pass `--workspace` per command: ```bash +sim workspaces list +sim configure --set-workspace 2f6d0b1c-8a34-4d92-b7e5-31c8a0f45d67 sim workflows list --workspace 9b4c7e02-1d58-4f36-a0c9-6e2b85df413a ``` -`sim login --workspace ` preselects a workspace in the picker, and -re-logging into an existing profile preselects the one already configured. +With the pairing-code handoff you choose the default workspace on the approval +page instead. -To save another workspace without minting or copying another personal key, add -a workspace profile: +To target another workspace without a second login, add a workspace profile: ```bash -sim workspaces list sim profile add acme --workspace 7e2d9c14-6b83-4a55-8f01-c4d3e9a76b28 sim --profile acme whoami ``` The new profile stores `auth_profile = default` and its own workspace. Omit -`--workspace` in an interactive terminal to choose from the workspaces the -active key can access; scripts must provide the workspace ID explicitly. The -picker is capped at 1,000 entries and asks for an explicit ID above that. +`--workspace` in an interactive terminal to choose from the workspaces your +login can access; scripts must provide the workspace ID explicitly. The picker +is capped at 1,000 entries and asks for an explicit ID above that. ## Checking who you are @@ -74,9 +150,10 @@ sim whoami # resolved settings, plus a live check that they work sim whoami --no-verify # resolved settings only, no request ``` -Prints the resolved endpoint, workspace, and output format, and which source each -value came from, then reads the configured workspace to prove the key is accepted -and can reach it. +Prints the resolved endpoint, workspace, and output format, which source each +value came from, and whether the profile holds an OAuth login or an API key, +then reads the configured workspace to prove the credential is accepted and can +reach it. It exits `0` when the check passes, `1` when the credentials are wrong, and `2` when the check could not be made at all — no workspace to check against, or an @@ -86,25 +163,31 @@ logging in again. ## Signing out ```bash -sim logout # remove the stored key +sim logout # sign out of Sim and remove the stored login sim logout --all # remove the profile entirely, including its settings ``` -A workspace profile that shares authentication cannot remove the shared key. +For an OAuth login, `sim logout` revokes that login's complete token family +before removing it from disk, including access tokens issued before earlier +rotations. Other machines that ran their own `sim login` remain signed in. To +cut off every independent login for the client, revoke the grant under +**Settings → General → Authorized apps**. + +A workspace profile that shares authentication cannot remove the shared login. Remove only that local profile with `sim logout --all --profile `, or log out of the authentication profile named by the error message. Removing an authentication profile entirely is refused until its workspace profiles are removed, so it cannot leave dangling references. -`sim logout` removes the key from disk but does **not** revoke it. Revoke keys in -Sim under **Settings → API keys**. +For a login created with `--method api-key`, `sim logout` removes the API key from +disk but does **not** revoke it. Revoke keys under **Settings → API keys**. ## Authenticating CI -Set the key and workspace in the environment; the CLI never reads or writes a -config file: +Set an API key and workspace in the environment; the CLI never reads or writes +a config file, and an explicit key outranks any stored login: ```bash export SIM_API_KEY="sim_…" @@ -148,7 +231,7 @@ sim workflows list --profile dev sim workflows list --profile prod ``` -Use workspace profiles when one personal key should target several workspaces: +Use workspace profiles when one login should target several workspaces: ```bash sim profile add marketing --workspace c3a70e58-9f21-4d6b-b842-05e7f19c6a3d @@ -174,21 +257,45 @@ Save it to avoid repeating the flag: sim configure --set-endpoint http://localhost:3000 --profile local ``` -## Where the key is stored +OAuth sign-in is available by default when server authentication is enabled. +`DISABLE_AUTH=true` disables OAuth. Older servers without OAuth support use the +pairing-code handoff. Keep the database schema current and drain app instances +that predate the OAuth token-family lifecycle before accepting OAuth traffic. +See [Sign in with Sim](/platform/self-hosting/authentication#sign-in-with-sim). + +## Where the login is stored -Keys live in `~/.sim/credentials`, written `0600`, separate from the non-secret +Logins live in `~/.sim/credentials`, written `0600`, separate from the non-secret `~/.sim/config`. Commit `config` to a dotfiles repo if you like; never `credentials`. ```ini title="~/.sim/credentials" [default] -api_key = sim_… - -[dev] +access_token = sim_oat_… +refresh_token = sim_ort_… +token_expires_at = 1788547200000 +oauth_issuer = https://www.sim.ai/api/auth +oauth_login_id = … +oauth_scope = offline_access api:read api:write + +[ci-box] api_key = sim_… ``` +A profile holds one login. A stored API key can be replaced after confirmation +or with `--yes`; a live OAuth login must be revoked with `sim logout` before +signing in again. Several `sim` commands running at once share one renewal, so +a parallel shell loop cannot sign itself out. + +Run `sim login` separately on each machine. Copying `~/.sim/credentials` copies +one single-use refresh-token family; simultaneous use from both copies is +treated as token replay and revokes that login. If a refresh response is lost +because the process or connection stops, the CLI does not retry the consumed +token: run `sim logout`, then `sim login` again. This fail-closed behavior keeps +a copied token from surviving an ambiguous refresh. + ## Organization audit logs -`sim audit-logs` requires a **personal** API key — the kind `sim login` issues. -A workspace-scoped key cannot read organization-level audit logs. +`sim audit-logs` requires a **personal** credential — an OAuth login, or the +personal API key `sim login --method api-key` issues. A workspace-scoped key cannot +read organization-level audit logs. diff --git a/apps/docs/content/docs/cli/billing.mdx b/apps/docs/content/docs/cli/billing.mdx index 2df8ad9d9dc..7e9d7cf6efa 100644 --- a/apps/docs/content/docs/cli/billing.mdx +++ b/apps/docs/content/docs/cli/billing.mdx @@ -13,7 +13,7 @@ Every command below also accepts the [global options](/cli/commands#global-optio sim billing status [options] ``` -Show billing status and current-period credit usage (credits and storage require a personal API key) +Show billing status and current-period credit usage (credits and storage require an OAuth login or personal API key) **Options** @@ -21,7 +21,7 @@ Show billing status and current-period credit usage (credits and storage require | Option | Required | Description | | --- | --- | --- | -| `--all-workspaces` | No | Do not filter to the configured workspace (personal API key required for account-wide access). | +| `--all-workspaces` | No | Do not filter to the configured workspace (OAuth login or personal API key required for account-wide access). | @@ -31,7 +31,7 @@ Show billing status and current-period credit usage (credits and storage require sim billing logs [options] ``` -List credit usage events (a personal API key reports only your own events; a workspace API key reports every member's in aggregate, unattributed) +List credit usage events (an OAuth login or personal API key reports only your events; a workspace API key reports every member's in aggregate, unattributed) **Options** @@ -44,6 +44,7 @@ List credit usage events (a personal API key reports only your own events; a wor | `--start-date ` | No | Custom period start (ISO 8601). | | `--end-date ` | No | Custom period end (ISO 8601). | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | -| `--all-workspaces` | No | Do not filter to the configured workspace (personal API key required for account-wide access). | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | +| `--all-workspaces` | No | Do not filter to the configured workspace (OAuth login or personal API key required for account-wide access). | diff --git a/apps/docs/content/docs/cli/blocks.mdx b/apps/docs/content/docs/cli/blocks.mdx index 9d95f9fc6d5..96166c9fee2 100644 --- a/apps/docs/content/docs/cli/blocks.mdx +++ b/apps/docs/content/docs/cli/blocks.mdx @@ -38,9 +38,9 @@ sim blocks list [options] | `--search ` | No | Case-insensitive substring match against the block id, name, and description. | | `--category ` | No | Restrict to one toolbar category. Accepted values: `blocks`, `tools`, `triggers`. | | `--capability ` | No | Restrict to blocks that can start a workflow — the `triggers` category, blocks declaring `triggerAllowed`, and blocks with trigger-mode fields. Accepted values: `trigger`. | -| `--source ` | No | Restrict to shipped blocks or to this workspace’s deployed custom blocks. Accepted values: `builtin`, `custom`. | +| `--source ` | No | Restrict to built-in blocks or this workspace's deployed custom blocks. Accepted values: `builtin`, `custom`. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `id`, `name`, `category`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | diff --git a/apps/docs/content/docs/cli/chat-deployments.mdx b/apps/docs/content/docs/cli/chat-deployments.mdx index 840e10a0a6c..59ab2d52d74 100644 --- a/apps/docs/content/docs/cli/chat-deployments.mdx +++ b/apps/docs/content/docs/cli/chat-deployments.mdx @@ -24,6 +24,6 @@ sim chat-deployments list [options] | `--no-is-active` | No | Send --is-active as false. | | `--sort-by ` | No | Field used to sort the result. Accepted values: `identifier`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | diff --git a/apps/docs/content/docs/cli/commands.mdx b/apps/docs/content/docs/cli/commands.mdx index e8a54f5acc6..a108204a7c3 100644 --- a/apps/docs/content/docs/cli/commands.mdx +++ b/apps/docs/content/docs/cli/commands.mdx @@ -52,7 +52,7 @@ These apply to every command, and may be written before or after it. | [`sim workflows`](/cli/workflows) | Manage workflows | | [`sim workspaces`](/cli/workspaces) | Manage workspaces | -## Authorize this terminal and store an API key for the profile +## Sign in through the browser and store the login for the profile ```bash sim login [options] @@ -64,13 +64,15 @@ sim login [options] | Option | Required | Description | | --- | --- | --- | -| `--scope ` | No | Key space to mint from: platform or copilot. Defaults to `platform`. | -| `--no-browser` | No | Print the URL instead of opening a browser. | -| `-y, --yes` | No | Overwrite an existing profile without prompting. | +| `--method ` | No | Credential to obtain: oauth requires OAuth support; api-key creates a permanent key through pairing (auto-selects when omitted). Accepted values: `oauth`, `api-key`. | +| `--no-browser` | No | Print the approval URL without opening it (either login method). | +| `--read-only` | No | Ask only for permission to read, never to change anything. | +| `--callback-port ` | No | Pin the local port the browser returns to. | +| `-y, --yes` | No | Overwrite an existing API-key profile without prompting. | -## Remove the profile's stored API key +## Sign out and remove the profile's stored login ```bash sim logout [options] diff --git a/apps/docs/content/docs/cli/credentials.mdx b/apps/docs/content/docs/cli/credentials.mdx index aec2144c459..16763637d19 100644 --- a/apps/docs/content/docs/cli/credentials.mdx +++ b/apps/docs/content/docs/cli/credentials.mdx @@ -15,7 +15,7 @@ Every command below also accepts the [global options](/cli/commands#global-optio sim credentials delete [options] ``` -Disconnect Credential (personal API key required) +Disconnect Credential (OAuth login or personal API key required) **Arguments** @@ -70,7 +70,7 @@ sim credentials list [options] | `--search ` | No | Case-insensitive substring match against the credential display name. | | `--sort-by ` | No | Field used to sort the result. Accepted values: `displayName`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | @@ -80,7 +80,7 @@ sim credentials list [options] sim credentials update [options] ``` -Update Credential (personal API key required) +Update Credential (OAuth login or personal API key required) **Arguments** @@ -103,6 +103,7 @@ Update Credential (personal API key required) | `--service-account-json ` | No | Write-only Google service-account JSON key. | | `--api-token ` | No | Write-only provider API token. | | `--domain ` | No | Provider account domain. | +| `--atlassian-product ` | No | Atlassian product to verify; defaults to Jira on create and preserves the saved product on reconnect. Accepted values: `jira`, `confluence`. | | `--signing-secret ` | No | Write-only webhook signing secret. | | `--bot-token ` | No | Write-only bot token. | | `--client-id ` | No | OAuth client identifier. | @@ -123,7 +124,7 @@ Update Credential (personal API key required) sim credentials create [options] ``` -Create a service-account credential using its discovered provider schema (personal API key required) +Create a service-account credential using its discovered provider schema (OAuth login or personal API key required) **Arguments** @@ -154,7 +155,7 @@ Create a service-account credential using its discovered provider schema (person sim credentials connect [options] ``` -Create a short-lived link for connecting an OAuth provider (personal API key required) +Create a short-lived link for connecting an OAuth provider (OAuth login or personal API key required) **Arguments** @@ -182,7 +183,7 @@ Create a short-lived link for connecting an OAuth provider (personal API key req sim credentials reconnect ``` -Create a short-lived link for reconnecting an OAuth credential (personal API key required) +Create a short-lived link for reconnecting an OAuth credential (OAuth login or personal API key required) **Arguments** diff --git a/apps/docs/content/docs/cli/custom-tools.mdx b/apps/docs/content/docs/cli/custom-tools.mdx index 097d4fb3fed..5c83e67c805 100644 --- a/apps/docs/content/docs/cli/custom-tools.mdx +++ b/apps/docs/content/docs/cli/custom-tools.mdx @@ -84,7 +84,7 @@ sim custom-tools list [options] | `--search ` | No | Case-insensitive substring match against the tool title. | | `--sort-by ` | No | Field used to sort the result. Accepted values: `title`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | diff --git a/apps/docs/content/docs/cli/files.mdx b/apps/docs/content/docs/cli/files.mdx index be9e2330c84..e952bf299b6 100644 --- a/apps/docs/content/docs/cli/files.mdx +++ b/apps/docs/content/docs/cli/files.mdx @@ -248,7 +248,7 @@ sim files share get sim files share set [options] ``` -Enable or disable sharing for a file (personal API key required) +Enable or disable sharing for a file (OAuth login or personal API key required) **Arguments** @@ -286,13 +286,13 @@ sim files list [options] | Option | Required | Description | | --- | --- | --- | | `--folder ` | No | Folder path as shown in the app; the leading / is optional. | -| `--recursive` | No | Whether the folder filter includes files in subfolders. Defaults to true when a search is set, false otherwise, so listing a folder shows that folder while searching one looks through everything in it. Ignored when no folder filter is set, which already spans the workspace. | +| `--recursive` | No | Include subfolders in the folder filter. Defaults to true when searching and false otherwise. Ignored without a folder filter. | | `--no-recursive` | No | Send --recursive as false. | | `--scope ` | No | Which lifecycle set to list: `active` (default) for live files, `archived` for files a delete soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too. Accepted values: `active`, `archived`. | | `--search ` | No | Case-insensitive substring match against the file name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `size`, `uploadedAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | @@ -536,7 +536,7 @@ sim files ls [path] [options] | Option | Required | Description | | --- | --- | --- | | `--search ` | No | Filter folders and resources by name. | -| `--limit ` | No | Maximum combined items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum combined items to return (0 for everything). Defaults to `0`. | diff --git a/apps/docs/content/docs/cli/knowledge.mdx b/apps/docs/content/docs/cli/knowledge.mdx index 5d697bb22b1..1dcd516b7fe 100644 --- a/apps/docs/content/docs/cli/knowledge.mdx +++ b/apps/docs/content/docs/cli/knowledge.mdx @@ -15,7 +15,7 @@ Every command below also accepts the [global options](/cli/commands#global-optio sim knowledge from-workspace-files create [options] ``` -Index files the workspace already stores (personal API key required) +Index files the workspace already stores (OAuth login or personal API key required) **Arguments** @@ -43,7 +43,7 @@ Index files the workspace already stores (personal API key required) sim knowledge tags save [options] ``` -Declare the tag definitions a knowledge base needs (personal API key required) +Declare the tag definitions a knowledge base needs (OAuth login or personal API key required) **Arguments** @@ -71,7 +71,7 @@ Declare the tag definitions a knowledge base needs (personal API key required) sim knowledge tags create [options] ``` -Create Tag (personal API key required) +Create Tag (OAuth login or personal API key required) **Arguments** @@ -101,7 +101,7 @@ Create Tag (personal API key required) sim knowledge tags delete [options] ``` -Delete Tag (personal API key required) +Delete Tag (OAuth login or personal API key required) **Arguments** @@ -130,7 +130,7 @@ Delete Tag (personal API key required) sim knowledge tags cleanup [options] ``` -Remove tag definitions no document still uses (personal API key required) +Remove tag definitions no document still uses (OAuth login or personal API key required) **Arguments** @@ -160,7 +160,7 @@ Remove tag definitions no document still uses (personal API key required) sim knowledge tags next-slot [options] ``` -Show which tag slot a create would take for a field type (personal API key required) +Show which tag slot a create would take for a field type (OAuth login or personal API key required) **Arguments** @@ -204,7 +204,7 @@ sim knowledge tags list sim knowledge tags usage ``` -Show how many documents and chunks carry each tag (personal API key required) +Show how many documents and chunks carry each tag (OAuth login or personal API key required) **Arguments** @@ -222,7 +222,7 @@ Show how many documents and chunks carry each tag (personal API key required) sim knowledge tags update [options] ``` -Update Tag (personal API key required) +Update Tag (OAuth login or personal API key required) **Arguments** @@ -252,7 +252,7 @@ Update Tag (personal API key required) sim knowledge chunks batch-update [options] ``` -Enable, disable, or delete many chunks at once (personal API key required) +Enable, disable, or delete many chunks at once (OAuth login or personal API key required) **Arguments** @@ -283,7 +283,7 @@ Enable, disable, or delete many chunks at once (personal API key required) sim knowledge chunks create [options] ``` -Create Chunk (personal API key required) +Create Chunk (OAuth login or personal API key required) **Arguments** @@ -314,7 +314,7 @@ Create Chunk (personal API key required) sim knowledge chunks delete [options] ``` -Delete Chunk (personal API key required) +Delete Chunk (OAuth login or personal API key required) **Arguments** @@ -344,7 +344,7 @@ Delete Chunk (personal API key required) sim knowledge chunks get ``` -Get Chunk (personal API key required) +Get Chunk (OAuth login or personal API key required) **Arguments** @@ -364,7 +364,7 @@ Get Chunk (personal API key required) sim knowledge chunks list [options] ``` -List Chunks (personal API key required) +List Chunks (OAuth login or personal API key required) **Arguments** @@ -388,6 +388,7 @@ List Chunks (personal API key required) | `--sort-by ` | No | Field used to sort the result. Accepted values: `chunkIndex`, `tokenCount`, `enabled`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | @@ -397,7 +398,7 @@ List Chunks (personal API key required) sim knowledge chunks update [options] ``` -Update Chunk (personal API key required) +Update Chunk (OAuth login or personal API key required) **Arguments** @@ -429,7 +430,7 @@ Update Chunk (personal API key required) sim knowledge documents batch-update [options] ``` -Enable or disable every matching document (personal API key required) +Enable or disable every matching document (OAuth login or personal API key required) **Arguments** @@ -525,6 +526,7 @@ sim knowledge documents list [options] | `--enabled-filter ` | No | Filter by whether documents are enabled for search. Accepted values: `all`, `enabled`, `disabled`. | | `--sort-by ` | No | Field used to sort the result. Sorting by `filename` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `filename`, `fileSize`, `tokenCount`, `chunkCount`, `uploadedAt`, `processingStatus`, `enabled`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | | `--tag-filters ` | No | A JSON-encoded array of at most 10 tag filters, using the same display-name shape as knowledge search: `[{"tagName":"category","operator":"eq","value":"billing"}]`. Every filter must hold, including two that name the same tag. A name that is not defined in this knowledge base is rejected, never ignored. | @@ -535,7 +537,7 @@ sim knowledge documents list [options] sim knowledge documents update [options] ``` -Update Document (personal API key required) +Update Document (OAuth login or personal API key required) **Arguments** @@ -636,7 +638,7 @@ sim knowledge create [options] sim knowledge connectors create [options] ``` -Create Knowledge Connector (personal API key required) +Create Knowledge Connector (OAuth login or personal API key required) **Arguments** @@ -668,7 +670,7 @@ Create Knowledge Connector (personal API key required) sim knowledge connectors delete [options] ``` -Delete Knowledge Connector (personal API key required) +Delete Knowledge Connector (OAuth login or personal API key required) **Arguments** @@ -699,7 +701,7 @@ Delete Knowledge Connector (personal API key required) sim knowledge connectors get ``` -Get Knowledge Connector (personal API key required) +Get Knowledge Connector (OAuth login or personal API key required) **Arguments** @@ -718,7 +720,7 @@ Get Knowledge Connector (personal API key required) sim knowledge connectors documents list [options] ``` -List Knowledge Connector Documents (personal API key required) +List Knowledge Connector Documents (OAuth login or personal API key required) **Arguments** @@ -740,6 +742,7 @@ List Knowledge Connector Documents (personal API key required) | `--include-excluded` | No | Include documents explicitly excluded by a user. | | `--no-include-excluded` | No | Send --include-excluded as false. | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | @@ -749,7 +752,7 @@ List Knowledge Connector Documents (personal API key required) sim knowledge connectors documents update [options] ``` -Update Knowledge Connector Documents (personal API key required) +Update Knowledge Connector Documents (OAuth login or personal API key required) **Arguments** @@ -779,7 +782,7 @@ Update Knowledge Connector Documents (personal API key required) sim knowledge connectors list [options] ``` -List Knowledge Connectors (personal API key required) +List Knowledge Connectors (OAuth login or personal API key required) **Arguments** @@ -799,7 +802,7 @@ List Knowledge Connectors (personal API key required) | --- | --- | --- | | `--sort-by ` | No | Field used to sort the result. Accepted values: `connectorType`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | @@ -809,7 +812,7 @@ List Knowledge Connectors (personal API key required) sim knowledge connectors sync [options] ``` -Queue a knowledge connector synchronization (personal API key required) +Queue a knowledge connector synchronization (OAuth login or personal API key required) **Arguments** @@ -839,7 +842,7 @@ Queue a knowledge connector synchronization (personal API key required) sim knowledge connectors update [options] ``` -Update Knowledge Connector (personal API key required) +Update Knowledge Connector (OAuth login or personal API key required) **Arguments** @@ -1001,12 +1004,12 @@ sim knowledge list [options] | Option | Required | Description | | --- | --- | --- | -| `--scope ` | No | Which lifecycle set to list: `active` (default) for live knowledge bases, `archived` for knowledge bases a `DELETE` archived and `POST /knowledge/{knowledgeBaseId}/restore` can bring back. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too. Accepted values: `active`, `archived`. | +| `--scope ` | No | Lifecycle scope: active or archived knowledge bases. Use Restore Knowledge Base to recover archived entries. Folder paths resolve only active folders, so filtering by an archived folder returns no matches. Accepted values: `active`, `archived`. | | `--folder ` | No | Folder path as shown in the app; the leading / is optional. | | `--search ` | No | Case-insensitive substring match against the resource name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | @@ -1119,7 +1122,7 @@ sim knowledge ls [path] [options] | Option | Required | Description | | --- | --- | --- | | `--search ` | No | Filter folders and resources by name. | -| `--limit ` | No | Maximum combined items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum combined items to return (0 for everything). Defaults to `0`. | diff --git a/apps/docs/content/docs/cli/logs.mdx b/apps/docs/content/docs/cli/logs.mdx index df85aaa0bf1..281dfb3bce5 100644 --- a/apps/docs/content/docs/cli/logs.mdx +++ b/apps/docs/content/docs/cli/logs.mdx @@ -53,7 +53,7 @@ sim logs stats [options] | `--level ` | No | Severity level to include. Accepted values: `info`, `error`. | | `--start-date ` | No | Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | | `--end-date ` | No | Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | -| `--segment-count ` | No | Number of equal time buckets to divide the window into, from 1 to 500. Exactly this many buckets are always returned. Buckets are never narrower than one minute, so on a short window the series extends past the end of the window rather than being compressed, and the trailing buckets are empty. | +| `--segment-count ` | No | Number of time buckets, up to 500. Exactly this many are returned, each at least one minute wide. Short windows extend past the requested end and include empty trailing buckets. | @@ -70,7 +70,7 @@ sim logs list [options] | Option | Required | Description | | --- | --- | --- | | `--workflow ` | No | Comma-separated workflow identifiers to include. An empty entry is rejected. At most 200 entries. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | -| `--trigger ` | No | Comma-separated trigger types to include. An empty entry is rejected. Values are matched exactly and are case-sensitive — every recorded trigger is lowercase, so `API` matches nothing while `api` matches. The vocabulary is open: it covers the core trigger types (`manual`, `api`, `schedule`, `chat`, `webhook`, `mcp`, `copilot`, `workflow`, `custom_block`) and the provider id of any webhook trigger (`slack`, `gmail`, `github`, …), so an unrecognized member is not rejected — it selects no runs. The literal value `all` is a sentinel that disables this filter entirely, so a list containing it returns runs of every trigger type; no real trigger type is named `all`. At most 100 entries. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--trigger ` | No | Comma-separated, lowercase trigger types or webhook provider IDs. Matching is exact and case-sensitive; unknown values select no runs. An empty entry is rejected. The sentinel `all` disables this filter, even when listed with other values. At most 100 entries. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--level ` | No | Severity level to include. Accepted values: `info`, `error`. | | `--start-date ` | No | Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | | `--end-date ` | No | Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | @@ -83,9 +83,10 @@ sim logs list [options] | `--include-trace-spans` | No | Include trace spans in JSON or YAML output (implies full detail). | | `--include-final-output` | No | Include final output in JSON or YAML output (implies full detail). | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | | `--status ` | No | Comma-separated execution statuses to include, from `pending` \| `running` \| `paused` \| `redacting` \| `completed` \| `failed` \| `cancelled`. An empty entry is rejected. ANDed with `level`, which reports severity rather than lifecycle. | | `--workflow-name ` | No | Case-insensitive substring match against the run's workflow name. Runs whose workflow has been deleted match nothing, because the name is no longer joinable. | -| `--include-job-runs` | No | Whether Chat and Sim-agent job runs join the sequence alongside workflow runs. Job runs report `kind: "job"`, carry no `workflow` summary, and never carry a cost ledger. They are dropped entirely — not partially matched — whenever a filter they cannot answer is set: by workflow, workflow name, folder, model, or status. A filter therefore never means two different things across the union. Accepted only when sorting by `startedAt`: job runs record cost as a document and no comparable status, so they cannot participate in the other orderings. | +| `--include-job-runs` | No | Include Chat and Sim-agent jobs alongside workflow runs. Jobs use `kind: "job"` and have no workflow or cost ledger. Workflow, folder, model, or status filters exclude jobs. This option is valid only when sorting by `startedAt`. | | `--no-include-job-runs` | No | Send --include-job-runs as false. | | `--run-id ` | No | Exact run identifier to match. | | `--sort-by ` | No | Field used to sort the result. `durationMs` and `cost` are null until a run settles; those runs order as though the value were below every recorded one, so they trail an ascending page and lead a descending one. Only `startedAt` can order Chat and Sim-agent job runs, so any other value is rejected when job runs are included. Accepted values: `startedAt`, `durationMs`, `cost`, `status`. | diff --git a/apps/docs/content/docs/cli/mcp-servers.mdx b/apps/docs/content/docs/cli/mcp-servers.mdx index db0f65a115a..bb9559b297b 100644 --- a/apps/docs/content/docs/cli/mcp-servers.mdx +++ b/apps/docs/content/docs/cli/mcp-servers.mdx @@ -23,13 +23,13 @@ sim mcp-servers create [options] | --- | --- | --- | | `--name ` | Yes | Server display name. | | `--description ` | No | Optional server description. | -| `--transport ` | No | Transport used to communicate with the server. Applied server-side as `streamable-http` when omitted on create. Accepted values: `streamable-http`. | +| `--transport ` | No | Transport protocol. Defaults to `streamable-http` on creation. Accepted values: `streamable-http`. | | `--url ` | Yes | Absolute HTTP or HTTPS endpoint URL without `{{ENV_VAR}}` references. It determines server identity and is immutable: delete and recreate the server to change endpoints. | | `--auth-type ` | No | Authentication method. When omitted, and no `headers` are sent, registration probes the endpoint once to classify it, falling back to `headers` when the probe fails or the server does not advertise OAuth. A server publishing RFC 9728 metadata is therefore stored as `oauth`, and headers configured afterwards will not authenticate — send this field explicitly to pin the method. Accepted values: `none`, `headers`, `oauth`. | | `--headers ` | No | Write-only request headers sent to the server. Replaced wholesale rather than merged on update: sending this field drops every stored header it does not repeat. (JSON, or @path / @- to read a file or stdin). | -| `--timeout ` | No | Per-request timeout in milliseconds. Applied server-side as 30000 when omitted on create. | -| `--retries ` | No | Number of retries per request. Applied server-side as 3 when omitted on create. | -| `--enabled` | No | Whether the server tools are available to workflows. Applied server-side as true when omitted on create. | +| `--timeout ` | No | Per-request timeout in milliseconds. Defaults to 30000 on creation. | +| `--retries ` | No | Number of retries per request. Defaults to 3 on creation. | +| `--enabled` | No | Whether workflows can use the server's tools. Defaults to true on creation. | | `--no-enabled` | No | Send --enabled as false. | | `--oauth-client-id ` | No | Pre-registered OAuth client identifier. Changing it on update revokes the stored OAuth grant and forces reauthorization. | | `--oauth-client-secret ` | No | Write-only pre-registered OAuth client secret. Sending it on update as null or a new value revokes the stored OAuth grant and forces reauthorization, as does switching away from OAuth authentication. (--oauth-client-secret null sends the word, not JSON null). | @@ -93,7 +93,7 @@ sim mcp-servers list [options] | `--search ` | No | Case-insensitive substring match against the server name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | @@ -103,7 +103,7 @@ sim mcp-servers list [options] sim mcp-servers tools list [options] ``` -List MCP Server Tools (personal API key required) +List MCP Server Tools (OAuth login or personal API key required) **Arguments** @@ -121,7 +121,7 @@ List MCP Server Tools (personal API key required) | Option | Required | Description | | --- | --- | --- | -| `--refresh` | No | Bypass the short-lived per-workspace tool cache and reconnect under your own credentials. A cached result reflects whichever workspace member last ran discovery, so this is the only way to pick up a tool added since then; it costs a live round trip. | +| `--refresh` | No | Refresh tools using your credentials. Otherwise results may reuse another workspace member's recent discovery and omit newly added tools. | | `--no-refresh` | No | Send --refresh as false. | @@ -150,13 +150,13 @@ sim mcp-servers update [options] | --- | --- | --- | | `--name ` | No | Server display name. | | `--description ` | No | Optional server description. | -| `--transport ` | No | Transport used to communicate with the server. Applied server-side as `streamable-http` when omitted on create. Accepted values: `streamable-http`. | +| `--transport ` | No | Transport protocol. Defaults to `streamable-http` on creation. Accepted values: `streamable-http`. | | `--url ` | No | Immutable server URL. When provided, it must equal the current URL; use delete and create to change endpoints. | | `--auth-type ` | No | Authentication method. When omitted, and no `headers` are sent, registration probes the endpoint once to classify it, falling back to `headers` when the probe fails or the server does not advertise OAuth. A server publishing RFC 9728 metadata is therefore stored as `oauth`, and headers configured afterwards will not authenticate — send this field explicitly to pin the method. Accepted values: `none`, `headers`, `oauth`. | | `--headers ` | No | Write-only request headers sent to the server. Replaced wholesale rather than merged on update: sending this field drops every stored header it does not repeat. (JSON, or @path / @- to read a file or stdin). | -| `--timeout ` | No | Per-request timeout in milliseconds. Applied server-side as 30000 when omitted on create. | -| `--retries ` | No | Number of retries per request. Applied server-side as 3 when omitted on create. | -| `--enabled` | No | Whether the server tools are available to workflows. Applied server-side as true when omitted on create. | +| `--timeout ` | No | Per-request timeout in milliseconds. Defaults to 30000 on creation. | +| `--retries ` | No | Number of retries per request. Defaults to 3 on creation. | +| `--enabled` | No | Whether workflows can use the server's tools. Defaults to true on creation. | | `--no-enabled` | No | Send --enabled as false. | | `--oauth-client-id ` | No | Pre-registered OAuth client identifier. Changing it on update revokes the stored OAuth grant and forces reauthorization. | | `--oauth-client-secret ` | No | Write-only pre-registered OAuth client secret. Sending it on update as null or a new value revokes the stored OAuth grant and forces reauthorization, as does switching away from OAuth authentication. (--oauth-client-secret null sends the word, not JSON null). | diff --git a/apps/docs/content/docs/cli/output.mdx b/apps/docs/content/docs/cli/output.mdx index b85aa9cb431..9c4630a5586 100644 --- a/apps/docs/content/docs/cli/output.mdx +++ b/apps/docs/content/docs/cli/output.mdx @@ -27,6 +27,15 @@ SIM_OUTPUT=yaml sim logs list > logs.yaml `json` and `yaml` emit the API's raw values, not the table's formatting — a duration stays `1500`, not `"1.5s"`. +Paginated lists include the rows under `data` and the continuation cursor under +`nextCursor`. A `null` cursor means no pages remain: + +```json +{ "data": [{ "runId": "9c4f0b7e-2d81-4a35-b6e9-70f1c8a2d543" }], "nextCursor": null } +``` + +Read list rows with `jq '.data[]'` and check for more pages with `jq '.nextCursor'`. + `table` formats for reading: timestamps without milliseconds, sizes as `4.2 MB`, booleans as `yes`/`no`, costs as `$0.0142`. Long cells are clipped to keep rows on one line; switch to `json` for the full value. diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx index 28bab99672f..9c39df61fc4 100644 --- a/apps/docs/content/docs/cli/reference.mdx +++ b/apps/docs/content/docs/cli/reference.mdx @@ -26,7 +26,7 @@ These apply to every command, and may be written before or after it. ## sim login -Authorize this terminal and store an API key for the profile +Sign in through the browser and store the login for the profile ```bash sim login [options] @@ -38,15 +38,17 @@ sim login [options] | Option | Required | Description | | --- | --- | --- | -| `--scope ` | No | Key space to mint from: platform or copilot. Defaults to `platform`. | -| `--no-browser` | No | Print the URL instead of opening a browser. | -| `-y, --yes` | No | Overwrite an existing profile without prompting. | +| `--method ` | No | Credential to obtain: oauth requires OAuth support; api-key creates a permanent key through pairing (auto-selects when omitted). Accepted values: `oauth`, `api-key`. | +| `--no-browser` | No | Print the approval URL without opening it (either login method). | +| `--read-only` | No | Ask only for permission to read, never to change anything. | +| `--callback-port ` | No | Pin the local port the browser returns to. | +| `-y, --yes` | No | Overwrite an existing API-key profile without prompting. | ## sim logout -Remove the profile's stored API key +Sign out and remove the profile's stored login ```bash sim logout [options] @@ -175,7 +177,7 @@ Also spelled `sim audit-log`. ### sim audit-logs get -Get Audit Log (personal API key required) +Get Audit Log (OAuth login or personal API key required) ```bash sim audit-logs get [options] @@ -197,13 +199,13 @@ sim audit-logs get [options] | Option | Required | Description | | --- | --- | --- | -| `--organization ` | No | Organization ID; defaults to your only organization, and is required when your account belongs to more than one (personal API key required). | +| `--organization ` | No | Organization ID; defaults to your only organization, and is required when your account belongs to more than one (OAuth login or personal API key required). | ### sim audit-logs list -List Audit Logs (personal API key required) +List Audit Logs (OAuth login or personal API key required) ```bash sim audit-logs list [options] @@ -223,9 +225,10 @@ sim audit-logs list [options] | `--include-departed` | No | Include actions by users who have left the organization. | | `--no-include-departed` | No | Send --include-departed as false. | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | -| `--organization ` | No | Organization ID; defaults to your only organization, and is required when your account belongs to more than one (personal API key required). | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | +| `--organization ` | No | Organization ID; defaults to your only organization, and is required when your account belongs to more than one (OAuth login or personal API key required). | | `--actor-email ` | No | Filter by actor email address. | -| `--all-workspaces` | No | Do not filter to the configured workspace (personal API key required for account-wide access). | +| `--all-workspaces` | No | Do not filter to the configured workspace (OAuth login or personal API key required for account-wide access). | @@ -233,7 +236,7 @@ sim audit-logs list [options] ### sim billing status -Show billing status and current-period credit usage (credits and storage require a personal API key) +Show billing status and current-period credit usage (credits and storage require an OAuth login or personal API key) ```bash sim billing status [options] @@ -245,13 +248,13 @@ sim billing status [options] | Option | Required | Description | | --- | --- | --- | -| `--all-workspaces` | No | Do not filter to the configured workspace (personal API key required for account-wide access). | +| `--all-workspaces` | No | Do not filter to the configured workspace (OAuth login or personal API key required for account-wide access). | ### sim billing logs -List credit usage events (a personal API key reports only your own events; a workspace API key reports every member's in aggregate, unattributed) +List credit usage events (an OAuth login or personal API key reports only your events; a workspace API key reports every member's in aggregate, unattributed) ```bash sim billing logs [options] @@ -268,7 +271,8 @@ sim billing logs [options] | `--start-date ` | No | Custom period start (ISO 8601). | | `--end-date ` | No | Custom period end (ISO 8601). | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | -| `--all-workspaces` | No | Do not filter to the configured workspace (personal API key required for account-wide access). | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | +| `--all-workspaces` | No | Do not filter to the configured workspace (OAuth login or personal API key required for account-wide access). | @@ -309,10 +313,10 @@ sim blocks list [options] | `--search ` | No | Case-insensitive substring match against the block id, name, and description. | | `--category ` | No | Restrict to one toolbar category. Accepted values: `blocks`, `tools`, `triggers`. | | `--capability ` | No | Restrict to blocks that can start a workflow — the `triggers` category, blocks declaring `triggerAllowed`, and blocks with trigger-mode fields. Accepted values: `trigger`. | -| `--source ` | No | Restrict to shipped blocks or to this workspace’s deployed custom blocks. Accepted values: `builtin`, `custom`. | +| `--source ` | No | Restrict to built-in blocks or this workspace's deployed custom blocks. Accepted values: `builtin`, `custom`. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `id`, `name`, `category`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | @@ -337,7 +341,7 @@ sim chat-deployments list [options] | `--no-is-active` | No | Send --is-active as false. | | `--sort-by ` | No | Field used to sort the result. Accepted values: `identifier`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | @@ -367,7 +371,7 @@ Also spelled `sim credential`. ### sim credentials delete -Disconnect Credential (personal API key required) +Disconnect Credential (OAuth login or personal API key required) ```bash sim credentials delete [options] @@ -430,13 +434,13 @@ sim credentials list [options] | `--search ` | No | Case-insensitive substring match against the credential display name. | | `--sort-by ` | No | Field used to sort the result. Accepted values: `displayName`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | ### sim credentials update -Update Credential (personal API key required) +Update Credential (OAuth login or personal API key required) ```bash sim credentials update [options] @@ -463,6 +467,7 @@ sim credentials update [options] | `--service-account-json ` | No | Write-only Google service-account JSON key. | | `--api-token ` | No | Write-only provider API token. | | `--domain ` | No | Provider account domain. | +| `--atlassian-product ` | No | Atlassian product to verify; defaults to Jira on create and preserves the saved product on reconnect. Accepted values: `jira`, `confluence`. | | `--signing-secret ` | No | Write-only webhook signing secret. | | `--bot-token ` | No | Write-only bot token. | | `--client-id ` | No | OAuth client identifier. | @@ -479,7 +484,7 @@ sim credentials update [options] ### sim credentials create -Create a service-account credential using its discovered provider schema (personal API key required) +Create a service-account credential using its discovered provider schema (OAuth login or personal API key required) ```bash sim credentials create [options] @@ -510,7 +515,7 @@ sim credentials create [options] ### sim credentials connect -Create a short-lived link for connecting an OAuth provider (personal API key required) +Create a short-lived link for connecting an OAuth provider (OAuth login or personal API key required) ```bash sim credentials connect [options] @@ -538,7 +543,7 @@ sim credentials connect [options] ### sim credentials reconnect -Create a short-lived link for reconnecting an OAuth credential (personal API key required) +Create a short-lived link for reconnecting an OAuth credential (OAuth login or personal API key required) ```bash sim credentials reconnect @@ -641,7 +646,7 @@ sim custom-tools list [options] | `--search ` | No | Case-insensitive substring match against the tool title. | | `--sort-by ` | No | Field used to sort the result. Accepted values: `title`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | @@ -936,7 +941,7 @@ sim files share get ### sim files share set -Enable or disable sharing for a file (personal API key required) +Enable or disable sharing for a file (OAuth login or personal API key required) ```bash sim files share set [options] @@ -980,13 +985,13 @@ sim files list [options] | Option | Required | Description | | --- | --- | --- | | `--folder ` | No | Folder path as shown in the app; the leading / is optional. | -| `--recursive` | No | Whether the folder filter includes files in subfolders. Defaults to true when a search is set, false otherwise, so listing a folder shows that folder while searching one looks through everything in it. Ignored when no folder filter is set, which already spans the workspace. | +| `--recursive` | No | Include subfolders in the folder filter. Defaults to true when searching and false otherwise. Ignored without a folder filter. | | `--no-recursive` | No | Send --recursive as false. | | `--scope ` | No | Which lifecycle set to list: `active` (default) for live files, `archived` for files a delete soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too. Accepted values: `active`, `archived`. | | `--search ` | No | Case-insensitive substring match against the file name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `size`, `uploadedAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | @@ -1250,7 +1255,7 @@ sim files ls [path] [options] | Option | Required | Description | | --- | --- | --- | | `--search ` | No | Filter folders and resources by name. | -| `--limit ` | No | Maximum combined items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum combined items to return (0 for everything). Defaults to `0`. | @@ -1278,7 +1283,7 @@ Also spelled `sim kb`. ### sim knowledge from-workspace-files create -Index files the workspace already stores (personal API key required) +Index files the workspace already stores (OAuth login or personal API key required) ```bash sim knowledge from-workspace-files create [options] @@ -1306,7 +1311,7 @@ sim knowledge from-workspace-files create [options] ### sim knowledge tags save -Declare the tag definitions a knowledge base needs (personal API key required) +Declare the tag definitions a knowledge base needs (OAuth login or personal API key required) ```bash sim knowledge tags save [options] @@ -1334,7 +1339,7 @@ sim knowledge tags save [options] ### sim knowledge tags create -Create Tag (personal API key required) +Create Tag (OAuth login or personal API key required) ```bash sim knowledge tags create [options] @@ -1364,7 +1369,7 @@ sim knowledge tags create [options] ### sim knowledge tags delete -Delete Tag (personal API key required) +Delete Tag (OAuth login or personal API key required) ```bash sim knowledge tags delete [options] @@ -1393,7 +1398,7 @@ sim knowledge tags delete [options] ### sim knowledge tags cleanup -Remove tag definitions no document still uses (personal API key required) +Remove tag definitions no document still uses (OAuth login or personal API key required) ```bash sim knowledge tags cleanup [options] @@ -1423,7 +1428,7 @@ sim knowledge tags cleanup [options] ### sim knowledge tags next-slot -Show which tag slot a create would take for a field type (personal API key required) +Show which tag slot a create would take for a field type (OAuth login or personal API key required) ```bash sim knowledge tags next-slot [options] @@ -1469,7 +1474,7 @@ sim knowledge tags list ### sim knowledge tags usage -Show how many documents and chunks carry each tag (personal API key required) +Show how many documents and chunks carry each tag (OAuth login or personal API key required) ```bash sim knowledge tags usage @@ -1487,7 +1492,7 @@ sim knowledge tags usage ### sim knowledge tags update -Update Tag (personal API key required) +Update Tag (OAuth login or personal API key required) ```bash sim knowledge tags update [options] @@ -1517,7 +1522,7 @@ sim knowledge tags update [options] ### sim knowledge chunks batch-update -Enable, disable, or delete many chunks at once (personal API key required) +Enable, disable, or delete many chunks at once (OAuth login or personal API key required) ```bash sim knowledge chunks batch-update [options] @@ -1548,7 +1553,7 @@ sim knowledge chunks batch-update [options] ### sim knowledge chunks create -Create Chunk (personal API key required) +Create Chunk (OAuth login or personal API key required) ```bash sim knowledge chunks create [options] @@ -1579,7 +1584,7 @@ sim knowledge chunks create [options] ### sim knowledge chunks delete -Delete Chunk (personal API key required) +Delete Chunk (OAuth login or personal API key required) ```bash sim knowledge chunks delete [options] @@ -1609,7 +1614,7 @@ sim knowledge chunks delete [options] ### sim knowledge chunks get -Get Chunk (personal API key required) +Get Chunk (OAuth login or personal API key required) ```bash sim knowledge chunks get @@ -1629,7 +1634,7 @@ sim knowledge chunks get ### sim knowledge chunks list -List Chunks (personal API key required) +List Chunks (OAuth login or personal API key required) ```bash sim knowledge chunks list [options] @@ -1657,12 +1662,13 @@ sim knowledge chunks list [options] | `--sort-by ` | No | Field used to sort the result. Accepted values: `chunkIndex`, `tokenCount`, `enabled`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | ### sim knowledge chunks update -Update Chunk (personal API key required) +Update Chunk (OAuth login or personal API key required) ```bash sim knowledge chunks update [options] @@ -1694,7 +1700,7 @@ sim knowledge chunks update [options] ### sim knowledge documents batch-update -Enable or disable every matching document (personal API key required) +Enable or disable every matching document (OAuth login or personal API key required) ```bash sim knowledge documents batch-update [options] @@ -1800,13 +1806,14 @@ sim knowledge documents list [options] | `--enabled-filter ` | No | Filter by whether documents are enabled for search. Accepted values: `all`, `enabled`, `disabled`. | | `--sort-by ` | No | Field used to sort the result. Sorting by `filename` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `filename`, `fileSize`, `tokenCount`, `chunkCount`, `uploadedAt`, `processingStatus`, `enabled`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | | `--tag-filters ` | No | A JSON-encoded array of at most 10 tag filters, using the same display-name shape as knowledge search: `[{"tagName":"category","operator":"eq","value":"billing"}]`. Every filter must hold, including two that name the same tag. A name that is not defined in this knowledge base is rejected, never ignored. | ### sim knowledge documents update -Update Document (personal API key required) +Update Document (OAuth login or personal API key required) ```bash sim knowledge documents update [options] @@ -1911,7 +1918,7 @@ sim knowledge create [options] ### sim knowledge connectors create -Create Knowledge Connector (personal API key required) +Create Knowledge Connector (OAuth login or personal API key required) ```bash sim knowledge connectors create [options] @@ -1943,7 +1950,7 @@ sim knowledge connectors create [options] ### sim knowledge connectors delete -Delete Knowledge Connector (personal API key required) +Delete Knowledge Connector (OAuth login or personal API key required) ```bash sim knowledge connectors delete [options] @@ -1974,7 +1981,7 @@ sim knowledge connectors delete [options] ### sim knowledge connectors get -Get Knowledge Connector (personal API key required) +Get Knowledge Connector (OAuth login or personal API key required) ```bash sim knowledge connectors get @@ -1993,7 +2000,7 @@ sim knowledge connectors get ### sim knowledge connectors documents list -List Knowledge Connector Documents (personal API key required) +List Knowledge Connector Documents (OAuth login or personal API key required) ```bash sim knowledge connectors documents list [options] @@ -2019,12 +2026,13 @@ sim knowledge connectors documents list [options | `--include-excluded` | No | Include documents explicitly excluded by a user. | | `--no-include-excluded` | No | Send --include-excluded as false. | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | ### sim knowledge connectors documents update -Update Knowledge Connector Documents (personal API key required) +Update Knowledge Connector Documents (OAuth login or personal API key required) ```bash sim knowledge connectors documents update [options] @@ -2054,7 +2062,7 @@ sim knowledge connectors documents update [optio ### sim knowledge connectors list -List Knowledge Connectors (personal API key required) +List Knowledge Connectors (OAuth login or personal API key required) ```bash sim knowledge connectors list [options] @@ -2078,13 +2086,13 @@ sim knowledge connectors list [options] | --- | --- | --- | | `--sort-by ` | No | Field used to sort the result. Accepted values: `connectorType`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | ### sim knowledge connectors sync -Queue a knowledge connector synchronization (personal API key required) +Queue a knowledge connector synchronization (OAuth login or personal API key required) ```bash sim knowledge connectors sync [options] @@ -2114,7 +2122,7 @@ sim knowledge connectors sync [options] ### sim knowledge connectors update -Update Knowledge Connector (personal API key required) +Update Knowledge Connector (OAuth login or personal API key required) ```bash sim knowledge connectors update [options] @@ -2294,12 +2302,12 @@ sim knowledge list [options] | Option | Required | Description | | --- | --- | --- | -| `--scope ` | No | Which lifecycle set to list: `active` (default) for live knowledge bases, `archived` for knowledge bases a `DELETE` archived and `POST /knowledge/{knowledgeBaseId}/restore` can bring back. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too. Accepted values: `active`, `archived`. | +| `--scope ` | No | Lifecycle scope: active or archived knowledge bases. Use Restore Knowledge Base to recover archived entries. Folder paths resolve only active folders, so filtering by an archived folder returns no matches. Accepted values: `active`, `archived`. | | `--folder ` | No | Folder path as shown in the app; the leading / is optional. | | `--search ` | No | Case-insensitive substring match against the resource name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | @@ -2422,7 +2430,7 @@ sim knowledge ls [path] [options] | Option | Required | Description | | --- | --- | --- | | `--search ` | No | Filter folders and resources by name. | -| `--limit ` | No | Maximum combined items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum combined items to return (0 for everything). Defaults to `0`. | @@ -2496,7 +2504,7 @@ sim logs stats [options] | `--level ` | No | Severity level to include. Accepted values: `info`, `error`. | | `--start-date ` | No | Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | | `--end-date ` | No | Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | -| `--segment-count ` | No | Number of equal time buckets to divide the window into, from 1 to 500. Exactly this many buckets are always returned. Buckets are never narrower than one minute, so on a short window the series extends past the end of the window rather than being compressed, and the trailing buckets are empty. | +| `--segment-count ` | No | Number of time buckets, up to 500. Exactly this many are returned, each at least one minute wide. Short windows extend past the requested end and include empty trailing buckets. | @@ -2515,7 +2523,7 @@ sim logs list [options] | Option | Required | Description | | --- | --- | --- | | `--workflow ` | No | Comma-separated workflow identifiers to include. An empty entry is rejected. At most 200 entries. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | -| `--trigger ` | No | Comma-separated trigger types to include. An empty entry is rejected. Values are matched exactly and are case-sensitive — every recorded trigger is lowercase, so `API` matches nothing while `api` matches. The vocabulary is open: it covers the core trigger types (`manual`, `api`, `schedule`, `chat`, `webhook`, `mcp`, `copilot`, `workflow`, `custom_block`) and the provider id of any webhook trigger (`slack`, `gmail`, `github`, …), so an unrecognized member is not rejected — it selects no runs. The literal value `all` is a sentinel that disables this filter entirely, so a list containing it returns runs of every trigger type; no real trigger type is named `all`. At most 100 entries. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--trigger ` | No | Comma-separated, lowercase trigger types or webhook provider IDs. Matching is exact and case-sensitive; unknown values select no runs. An empty entry is rejected. The sentinel `all` disables this filter, even when listed with other values. At most 100 entries. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--level ` | No | Severity level to include. Accepted values: `info`, `error`. | | `--start-date ` | No | Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | | `--end-date ` | No | Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | @@ -2528,9 +2536,10 @@ sim logs list [options] | `--include-trace-spans` | No | Include trace spans in JSON or YAML output (implies full detail). | | `--include-final-output` | No | Include final output in JSON or YAML output (implies full detail). | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | | `--status ` | No | Comma-separated execution statuses to include, from `pending` \| `running` \| `paused` \| `redacting` \| `completed` \| `failed` \| `cancelled`. An empty entry is rejected. ANDed with `level`, which reports severity rather than lifecycle. | | `--workflow-name ` | No | Case-insensitive substring match against the run's workflow name. Runs whose workflow has been deleted match nothing, because the name is no longer joinable. | -| `--include-job-runs` | No | Whether Chat and Sim-agent job runs join the sequence alongside workflow runs. Job runs report `kind: "job"`, carry no `workflow` summary, and never carry a cost ledger. They are dropped entirely — not partially matched — whenever a filter they cannot answer is set: by workflow, workflow name, folder, model, or status. A filter therefore never means two different things across the union. Accepted only when sorting by `startedAt`: job runs record cost as a document and no comparable status, so they cannot participate in the other orderings. | +| `--include-job-runs` | No | Include Chat and Sim-agent jobs alongside workflow runs. Jobs use `kind: "job"` and have no workflow or cost ledger. Workflow, folder, model, or status filters exclude jobs. This option is valid only when sorting by `startedAt`. | | `--no-include-job-runs` | No | Send --include-job-runs as false. | | `--run-id ` | No | Exact run identifier to match. | | `--sort-by ` | No | Field used to sort the result. `durationMs` and `cost` are null until a run settles; those runs order as though the value were below every recorded one, so they trail an ascending page and lead a descending one. Only `startedAt` can order Chat and Sim-agent job runs, so any other value is rejected when job runs are included. Accepted values: `startedAt`, `durationMs`, `cost`, `status`. | @@ -2583,13 +2592,13 @@ sim mcp-servers create [options] | --- | --- | --- | | `--name ` | Yes | Server display name. | | `--description ` | No | Optional server description. | -| `--transport ` | No | Transport used to communicate with the server. Applied server-side as `streamable-http` when omitted on create. Accepted values: `streamable-http`. | +| `--transport ` | No | Transport protocol. Defaults to `streamable-http` on creation. Accepted values: `streamable-http`. | | `--url ` | Yes | Absolute HTTP or HTTPS endpoint URL without `{{ENV_VAR}}` references. It determines server identity and is immutable: delete and recreate the server to change endpoints. | | `--auth-type ` | No | Authentication method. When omitted, and no `headers` are sent, registration probes the endpoint once to classify it, falling back to `headers` when the probe fails or the server does not advertise OAuth. A server publishing RFC 9728 metadata is therefore stored as `oauth`, and headers configured afterwards will not authenticate — send this field explicitly to pin the method. Accepted values: `none`, `headers`, `oauth`. | | `--headers ` | No | Write-only request headers sent to the server. Replaced wholesale rather than merged on update: sending this field drops every stored header it does not repeat. (JSON, or @path / @- to read a file or stdin). | -| `--timeout ` | No | Per-request timeout in milliseconds. Applied server-side as 30000 when omitted on create. | -| `--retries ` | No | Number of retries per request. Applied server-side as 3 when omitted on create. | -| `--enabled` | No | Whether the server tools are available to workflows. Applied server-side as true when omitted on create. | +| `--timeout ` | No | Per-request timeout in milliseconds. Defaults to 30000 on creation. | +| `--retries ` | No | Number of retries per request. Defaults to 3 on creation. | +| `--enabled` | No | Whether workflows can use the server's tools. Defaults to true on creation. | | `--no-enabled` | No | Send --enabled as false. | | `--oauth-client-id ` | No | Pre-registered OAuth client identifier. Changing it on update revokes the stored OAuth grant and forces reauthorization. | | `--oauth-client-secret ` | No | Write-only pre-registered OAuth client secret. Sending it on update as null or a new value revokes the stored OAuth grant and forces reauthorization, as does switching away from OAuth authentication. (--oauth-client-secret null sends the word, not JSON null). | @@ -2659,13 +2668,13 @@ sim mcp-servers list [options] | `--search ` | No | Case-insensitive substring match against the server name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | ### sim mcp-servers tools list -List MCP Server Tools (personal API key required) +List MCP Server Tools (OAuth login or personal API key required) ```bash sim mcp-servers tools list [options] @@ -2687,7 +2696,7 @@ sim mcp-servers tools list [options] | Option | Required | Description | | --- | --- | --- | -| `--refresh` | No | Bypass the short-lived per-workspace tool cache and reconnect under your own credentials. A cached result reflects whichever workspace member last ran discovery, so this is the only way to pick up a tool added since then; it costs a live round trip. | +| `--refresh` | No | Refresh tools using your credentials. Otherwise results may reuse another workspace member's recent discovery and omit newly added tools. | | `--no-refresh` | No | Send --refresh as false. | @@ -2718,13 +2727,13 @@ sim mcp-servers update [options] | --- | --- | --- | | `--name ` | No | Server display name. | | `--description ` | No | Optional server description. | -| `--transport ` | No | Transport used to communicate with the server. Applied server-side as `streamable-http` when omitted on create. Accepted values: `streamable-http`. | +| `--transport ` | No | Transport protocol. Defaults to `streamable-http` on creation. Accepted values: `streamable-http`. | | `--url ` | No | Immutable server URL. When provided, it must equal the current URL; use delete and create to change endpoints. | | `--auth-type ` | No | Authentication method. When omitted, and no `headers` are sent, registration probes the endpoint once to classify it, falling back to `headers` when the probe fails or the server does not advertise OAuth. A server publishing RFC 9728 metadata is therefore stored as `oauth`, and headers configured afterwards will not authenticate — send this field explicitly to pin the method. Accepted values: `none`, `headers`, `oauth`. | | `--headers ` | No | Write-only request headers sent to the server. Replaced wholesale rather than merged on update: sending this field drops every stored header it does not repeat. (JSON, or @path / @- to read a file or stdin). | -| `--timeout ` | No | Per-request timeout in milliseconds. Applied server-side as 30000 when omitted on create. | -| `--retries ` | No | Number of retries per request. Applied server-side as 3 when omitted on create. | -| `--enabled` | No | Whether the server tools are available to workflows. Applied server-side as true when omitted on create. | +| `--timeout ` | No | Per-request timeout in milliseconds. Defaults to 30000 on creation. | +| `--retries ` | No | Number of retries per request. Defaults to 3 on creation. | +| `--enabled` | No | Whether workflows can use the server's tools. Defaults to true on creation. | | `--no-enabled` | No | Send --enabled as false. | | `--oauth-client-id ` | No | Pre-registered OAuth client identifier. Changing it on update revokes the stored OAuth grant and forces reauthorization. | | `--oauth-client-secret ` | No | Write-only pre-registered OAuth client secret. Sending it on update as null or a new value revokes the stored OAuth grant and forces reauthorization, as does switching away from OAuth authentication. (--oauth-client-secret null sends the word, not JSON null). | @@ -2747,7 +2756,7 @@ Also spelled `sim sandbox`. ### sim sandboxes create -Create Sandbox (personal API key required) +Create Sandbox (OAuth login or personal API key required) ```bash sim sandboxes create [options] @@ -2769,7 +2778,7 @@ sim sandboxes create [options] ### sim sandboxes delete -Delete Sandbox (personal API key required) +Delete Sandbox (OAuth login or personal API key required) ```bash sim sandboxes delete [options] @@ -2830,13 +2839,13 @@ sim sandboxes list [options] | `--search ` | No | Case-insensitive substring match against the sandbox name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | ### sim sandboxes update -Update Sandbox (personal API key required) +Update Sandbox (OAuth login or personal API key required) ```bash sim sandboxes update [options] @@ -2872,7 +2881,7 @@ Also spelled `sim secret`. ### sim secrets delete -Delete Secret (personal API key required) +Delete Secret (OAuth login or personal API key required) ```bash sim secrets delete [options] @@ -2901,7 +2910,7 @@ sim secrets delete [options] ### sim secrets list -List Secrets (personal API key required) +List Secrets (OAuth login or personal API key required) ```bash sim secrets list [options] @@ -2917,13 +2926,13 @@ sim secrets list [options] | `--search ` | No | Case-insensitive substring match against the secret name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | ### sim secrets set -Create or replace a named secret (personal API key required) +Create or replace a named secret (OAuth login or personal API key required) ```bash sim secrets set [options] @@ -2959,7 +2968,7 @@ Also spelled `sim skill`. ### sim skills create -Create Skill (personal API key required) +Create Skill (OAuth login or personal API key required) ```bash sim skills create [options] @@ -2979,7 +2988,7 @@ sim skills create [options] ### sim skills delete -Delete Skill (personal API key required) +Delete Skill (OAuth login or personal API key required) ```bash sim skills delete [options] @@ -3025,7 +3034,7 @@ sim skills get ### sim skills editors create -Grant Skill Editor (personal API key required) +Grant Skill Editor (OAuth login or personal API key required) ```bash sim skills editors create [options] @@ -3077,13 +3086,13 @@ sim skills editors list [options] | --- | --- | --- | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `email`, `name`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | ### sim skills editors delete -Revoke Skill Editor (personal API key required) +Revoke Skill Editor (OAuth login or personal API key required) ```bash sim skills editors delete [options] @@ -3127,13 +3136,13 @@ sim skills list [options] | `--search ` | No | Case-insensitive substring match against the skill name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | ### sim skills update -Update Skill (personal API key required) +Update Skill (OAuth login or personal API key required) ```bash sim skills update [options] @@ -3560,6 +3569,7 @@ sim tables rows list [options] | Option | Required | Description | | --- | --- | --- | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | | `--include-run-state` | No | Include per-workflow-group run state on every returned row. Off by default: run state is a separate sidecar read and its `blockErrors` are unbounded, so a full page carries it only when asked. Caps `limit` at 200. | | `--no-include-run-state` | No | Send --include-run-state as false. | @@ -3592,6 +3602,7 @@ sim tables rows query [options] | `--filter ` | No | Condition: {"field":"status","op":"eq","value":"active"}. Groups: {"all":[{"field":"status","op":"eq","value":"active"}]} or {"any":[{"field":"status","op":"eq","value":"active"}]}; group entries may also be nested groups. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | | `--sort ` | No | Ordered sort keys: [{"field":"createdAt","direction":"desc"}] (direction: asc or desc) (JSON, or @path / @- to read a file or stdin). | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | | `--include-run-state` | No | Include per-workflow-group run state on every returned row. Off by default: run state is a separate sidecar read and its `blockErrors` are unbounded, so a full page carries it only when asked. Incompatible with `limit: 0`, and caps `limit` at 200. | | `--no-include-run-state` | No | Send --include-run-state as false. | @@ -4340,7 +4351,7 @@ sim tables list [options] | `--search ` | No | Case-insensitive substring match against the resource name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | @@ -4522,7 +4533,7 @@ sim tables ls [path] [options] | Option | Required | Description | | --- | --- | --- | | `--search ` | No | Filter folders and resources by name. | -| `--limit ` | No | Maximum combined items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum combined items to return (0 for everything). Defaults to `0`. | @@ -4548,7 +4559,7 @@ sim tables mkdir ### sim tools execute -Run one built-in tool and print what it produced (personal API key required) +Run one built-in tool and print what it produced (OAuth login or personal API key required) ```bash sim tools execute [options] @@ -4613,7 +4624,7 @@ sim tools list [options] | `--oauth-provider ` | No | Restrict to tools that authenticate against this OAuth service. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `id`, `name`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | @@ -4621,7 +4632,7 @@ sim tools list [options] ### sim workflow-mcp-servers create -Create Workflow MCP Server (personal API key required) +Create Workflow MCP Server (OAuth login or personal API key required) ```bash sim workflow-mcp-servers create [options] @@ -4643,7 +4654,7 @@ sim workflow-mcp-servers create [options] ### sim workflow-mcp-servers delete -Delete Workflow MCP Server (personal API key required) +Delete Workflow MCP Server (OAuth login or personal API key required) ```bash sim workflow-mcp-servers delete [options] @@ -4671,7 +4682,7 @@ sim workflow-mcp-servers delete [options] ### sim workflow-mcp-servers tools create -Publish Workflow As MCP Tool (personal API key required) +Publish Workflow As MCP Tool (OAuth login or personal API key required) ```bash sim workflow-mcp-servers tools create [options] @@ -4702,7 +4713,7 @@ sim workflow-mcp-servers tools create [options] ### sim workflow-mcp-servers tools list -List Workflow MCP Tools (personal API key required) +List Workflow MCP Tools (OAuth login or personal API key required) ```bash sim workflow-mcp-servers tools list @@ -4720,7 +4731,7 @@ sim workflow-mcp-servers tools list ### sim workflow-mcp-servers tools delete -Unpublish Workflow MCP Tool (personal API key required) +Unpublish Workflow MCP Tool (OAuth login or personal API key required) ```bash sim workflow-mcp-servers tools delete [options] @@ -4749,7 +4760,7 @@ sim workflow-mcp-servers tools delete [options] ### sim workflow-mcp-servers get -Get Workflow MCP Server (personal API key required) +Get Workflow MCP Server (OAuth login or personal API key required) ```bash sim workflow-mcp-servers get @@ -4767,7 +4778,7 @@ sim workflow-mcp-servers get ### sim workflow-mcp-servers list -List Workflow MCP Servers (personal API key required) +List Workflow MCP Servers (OAuth login or personal API key required) ```bash sim workflow-mcp-servers list [options] @@ -4781,13 +4792,13 @@ sim workflow-mcp-servers list [options] | --- | --- | --- | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | ### sim workflow-mcp-servers update -Update Workflow MCP Server (personal API key required) +Update Workflow MCP Server (OAuth login or personal API key required) ```bash sim workflow-mcp-servers update [options] @@ -4822,7 +4833,7 @@ Also spelled `sim workflow`. ### sim workflows activate create -Activate Workflow Version (personal API key required) +Activate Workflow Version (OAuth login or personal API key required) ```bash sim workflows activate create [options] @@ -4851,7 +4862,7 @@ sim workflows activate create [options] ### sim workflows operations apply -Apply Workflow Operations (personal API key required) +Apply Workflow Operations (OAuth login or personal API key required) ```bash sim workflows operations apply [options] @@ -4994,6 +5005,7 @@ sim workflows runs list [options] | `--start-date ` | No | Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | | `--end-date ` | No | Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | | `--order ` | No | Sort direction by run start time. This list is sortable only by run start time, so it takes `order` in place of `sortBy`/`sortOrder`, which it rejects. Accepted values: `asc`, `desc`. | @@ -5198,7 +5210,7 @@ sim workflows delete [options] ### sim workflows chat unpublish -Take a workflow’s chat deployment offline (personal API key required) +Take a workflow’s chat deployment offline (OAuth login or personal API key required) ```bash sim workflows chat unpublish [options] @@ -5226,7 +5238,7 @@ sim workflows chat unpublish [options] ### sim workflows chat status -Show a workflow’s chat deployment (personal API key required) +Show a workflow’s chat deployment (OAuth login or personal API key required) ```bash sim workflows chat status @@ -5244,7 +5256,7 @@ sim workflows chat status ### sim workflows chat publish -Publish or replace a workflow’s chat deployment (personal API key required) +Publish or replace a workflow’s chat deployment (OAuth login or personal API key required) ```bash sim workflows chat publish [options] @@ -5284,7 +5296,7 @@ sim workflows chat publish [options] ### sim workflows deploy -Deploy Workflow (personal API key required) +Deploy Workflow (OAuth login or personal API key required) ```bash sim workflows deploy [options] @@ -5366,7 +5378,7 @@ sim workflows run [options] | --- | --- | --- | | `--input ` | No | Trigger input as JSON (JSON, or @path / @- to read a file or stdin). | | `--async` | No | Queue the run and return immediately. | -| `--execution-timeout-seconds ` | No | Requested server-side timeout for an asynchronous run, in seconds. An upper bound, not the effective timeout: the run uses the smaller of this value and the plan's execution timeout, so requesting more than the plan allows silently yields the plan timeout. Rejected with `400` unless `async` is true. | +| `--execution-timeout-seconds ` | No | Maximum duration of an asynchronous run, in seconds, capped by the plan's execution timeout. Requires `async: true`; otherwise returns `400`. | | `--select-output ` | No | Return streamed outputs as blockName.path or childWorkflowId.blockName.path; selecting a child workflow applies to every invocation, requires --follow (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--include-file-base64` | No | Inline eligible output files as base64 content. Rejected when `async` is true. | | `--no-include-file-base64` | No | Send --include-file-base64 as false. | @@ -5439,7 +5451,7 @@ sim workflows deployment status ### sim workflows deployment update -Update Workflow Public API Access (personal API key required) +Update Workflow Public API Access (OAuth login or personal API key required) ```bash sim workflows deployment update [options] @@ -5485,7 +5497,7 @@ sim workflows state get ### sim workflows state replace -Replace Workflow State (personal API key required) +Replace Workflow State (OAuth login or personal API key required) ```bash sim workflows state replace [options] @@ -5562,6 +5574,7 @@ sim workflows versions list [options] | Option | Required | Description | | --- | --- | --- | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | @@ -5634,7 +5647,7 @@ sim workflows list [options] | `--folder ` | No | Folder path as shown in the app; the leading / is optional. | | `--deployed-only` | No | Return only workflows with an active deployment when true. | | `--no-deployed-only` | No | Send --deployed-only as false. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | | `--search ` | No | Case-insensitive substring match against the resource name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `position`, `name`, `createdAt`, `updatedAt`, `runCount`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | @@ -5680,7 +5693,7 @@ sim workflows restore ### sim workflows revert create -Revert Workflow To Version (personal API key required) +Revert Workflow To Version (OAuth login or personal API key required) ```bash sim workflows revert create [options] @@ -5709,7 +5722,7 @@ sim workflows revert create [options] ### sim workflows rollback -Rollback Workflow (personal API key required) +Rollback Workflow (OAuth login or personal API key required) ```bash sim workflows rollback [options] @@ -5738,7 +5751,7 @@ sim workflows rollback [options] ### sim workflows undeploy -Take a workflow out of deployment (personal API key required) +Take a workflow out of deployment (OAuth login or personal API key required) ```bash sim workflows undeploy [options] @@ -5838,7 +5851,7 @@ sim workflows ls [path] [options] | Option | Required | Description | | --- | --- | --- | | `--search ` | No | Filter folders and resources by name. | -| `--limit ` | No | Maximum combined items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum combined items to return (0 for everything). Defaults to `0`. | @@ -5886,7 +5899,7 @@ sim workspaces members [options] | Option | Required | Description | | --- | --- | --- | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | @@ -5906,6 +5919,6 @@ sim workspaces list [options] | --- | --- | --- | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | diff --git a/apps/docs/content/docs/cli/sandboxes.mdx b/apps/docs/content/docs/cli/sandboxes.mdx index 9a73cc47dae..4ebd42422e1 100644 --- a/apps/docs/content/docs/cli/sandboxes.mdx +++ b/apps/docs/content/docs/cli/sandboxes.mdx @@ -15,7 +15,7 @@ Every command below also accepts the [global options](/cli/commands#global-optio sim sandboxes create [options] ``` -Create Sandbox (personal API key required) +Create Sandbox (OAuth login or personal API key required) **Options** @@ -37,7 +37,7 @@ Create Sandbox (personal API key required) sim sandboxes delete [options] ``` -Delete Sandbox (personal API key required) +Delete Sandbox (OAuth login or personal API key required) **Arguments** @@ -90,7 +90,7 @@ sim sandboxes list [options] | `--search ` | No | Case-insensitive substring match against the sandbox name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | @@ -100,7 +100,7 @@ sim sandboxes list [options] sim sandboxes update [options] ``` -Update Sandbox (personal API key required) +Update Sandbox (OAuth login or personal API key required) **Arguments** diff --git a/apps/docs/content/docs/cli/scripting.mdx b/apps/docs/content/docs/cli/scripting.mdx index af4dc93cce9..990380699e8 100644 --- a/apps/docs/content/docs/cli/scripting.mdx +++ b/apps/docs/content/docs/cli/scripting.mdx @@ -69,13 +69,49 @@ sim tables rows query tbl_9f3c1a05d4b7426e8c2f0917ab35de64 --sort '[{"field":"cr ## Pagination -List commands page automatically up to `--limit`, which defaults to `100`. Pass -`--limit 0` to fetch everything: +Resource lists and directory `ls` follow every page by default. Pass `--limit N` +to cap the returned items: + +```bash +sim workflows list --output json +sim files ls /Reports --limit 50 +``` + +Table rows (including queries), logs, audit/billing events, workflow runs and +versions, and knowledge documents/chunks retain a default limit of `100`. +Connector document lists also use this cap. Use `--limit 0` to fetch every page +of a large dataset: ```bash sim logs list --limit 0 --output json > all-logs.json ``` +Pages are fetched sequentially, accumulated in memory, and printed as one +result. A large table can consume substantial memory; use filters or an explicit +limit when you only need a subset. Each API request fetches at most 100 items. + +Paginated JSON and YAML results have the shape `{ data: [...], nextCursor }`. +`nextCursor` is the cursor after the last returned row, or `null` when all pages +have been fetched. Scripts that read list rows should use `.data[]`: + +```bash +sim logs list --output json | jq -r '.data[].runId' +``` + +The capped dataset commands accept `--cursor` to resume from the previous +result's `nextCursor`. Keep the same resource, filters, and sort order, and stop +when the cursor is `null`: + +```bash +sim tables rows list tbl_9f3c1a05d4b7426e8c2f0917ab35de64 --limit 100 --output json +sim tables rows list tbl_9f3c1a05d4b7426e8c2f0917ab35de64 --limit 100 --cursor "$nextCursor" --output json +``` + +`--limit` applies to each invocation starting at its cursor. `--limit 0` with +`--cursor` fetches every remaining page. Table-row queries, logs (including +billing and audit logs), workflow run/version histories, and knowledge +document/chunk lists all support this continuation. + ## Destructive commands Deletions require an explicit selector **and** `--yes`. There is no "delete diff --git a/apps/docs/content/docs/cli/secrets.mdx b/apps/docs/content/docs/cli/secrets.mdx index f9bbbdb2cdb..67ce8e273eb 100644 --- a/apps/docs/content/docs/cli/secrets.mdx +++ b/apps/docs/content/docs/cli/secrets.mdx @@ -15,7 +15,7 @@ Every command below also accepts the [global options](/cli/commands#global-optio sim secrets delete [options] ``` -Delete Secret (personal API key required) +Delete Secret (OAuth login or personal API key required) **Arguments** @@ -44,7 +44,7 @@ Delete Secret (personal API key required) sim secrets list [options] ``` -List Secrets (personal API key required) +List Secrets (OAuth login or personal API key required) **Options** @@ -56,7 +56,7 @@ List Secrets (personal API key required) | `--search ` | No | Case-insensitive substring match against the secret name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | @@ -66,7 +66,7 @@ List Secrets (personal API key required) sim secrets set [options] ``` -Create or replace a named secret (personal API key required) +Create or replace a named secret (OAuth login or personal API key required) **Arguments** diff --git a/apps/docs/content/docs/cli/skills.mdx b/apps/docs/content/docs/cli/skills.mdx index 77d928a5ead..76bdf2da396 100644 --- a/apps/docs/content/docs/cli/skills.mdx +++ b/apps/docs/content/docs/cli/skills.mdx @@ -15,7 +15,7 @@ Every command below also accepts the [global options](/cli/commands#global-optio sim skills create [options] ``` -Create Skill (personal API key required) +Create Skill (OAuth login or personal API key required) **Options** @@ -35,7 +35,7 @@ Create Skill (personal API key required) sim skills delete [options] ``` -Delete Skill (personal API key required) +Delete Skill (OAuth login or personal API key required) **Arguments** @@ -79,7 +79,7 @@ sim skills get sim skills editors create [options] ``` -Grant Skill Editor (personal API key required) +Grant Skill Editor (OAuth login or personal API key required) **Arguments** @@ -125,7 +125,7 @@ sim skills editors list [options] | --- | --- | --- | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `email`, `name`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | @@ -135,7 +135,7 @@ sim skills editors list [options] sim skills editors delete [options] ``` -Revoke Skill Editor (personal API key required) +Revoke Skill Editor (OAuth login or personal API key required) **Arguments** @@ -173,7 +173,7 @@ sim skills list [options] | `--search ` | No | Case-insensitive substring match against the skill name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | @@ -183,7 +183,7 @@ sim skills list [options] sim skills update [options] ``` -Update Skill (personal API key required) +Update Skill (OAuth login or personal API key required) **Arguments** diff --git a/apps/docs/content/docs/cli/tables.mdx b/apps/docs/content/docs/cli/tables.mdx index f7d5b11166a..b1290e4af50 100644 --- a/apps/docs/content/docs/cli/tables.mdx +++ b/apps/docs/content/docs/cli/tables.mdx @@ -376,6 +376,7 @@ sim tables rows list [options] | Option | Required | Description | | --- | --- | --- | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | | `--include-run-state` | No | Include per-workflow-group run state on every returned row. Off by default: run state is a separate sidecar read and its `blockErrors` are unbounded, so a full page carries it only when asked. Caps `limit` at 200. | | `--no-include-run-state` | No | Send --include-run-state as false. | @@ -406,6 +407,7 @@ sim tables rows query [options] | `--filter ` | No | Condition: {"field":"status","op":"eq","value":"active"}. Groups: {"all":[{"field":"status","op":"eq","value":"active"}]} or {"any":[{"field":"status","op":"eq","value":"active"}]}; group entries may also be nested groups. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | | `--sort ` | No | Ordered sort keys: [{"field":"createdAt","direction":"desc"}] (direction: asc or desc) (JSON, or @path / @- to read a file or stdin). | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | | `--include-run-state` | No | Include per-workflow-group run state on every returned row. Off by default: run state is a separate sidecar read and its `blockErrors` are unbounded, so a full page carries it only when asked. Incompatible with `limit: 0`, and caps `limit` at 200. | | `--no-include-run-state` | No | Send --include-run-state as false. | @@ -1092,7 +1094,7 @@ sim tables list [options] | `--search ` | No | Case-insensitive substring match against the resource name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | @@ -1260,7 +1262,7 @@ sim tables ls [path] [options] | Option | Required | Description | | --- | --- | --- | | `--search ` | No | Filter folders and resources by name. | -| `--limit ` | No | Maximum combined items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum combined items to return (0 for everything). Defaults to `0`. | diff --git a/apps/docs/content/docs/cli/tools.mdx b/apps/docs/content/docs/cli/tools.mdx index 83bdb04bbc5..93f264a8186 100644 --- a/apps/docs/content/docs/cli/tools.mdx +++ b/apps/docs/content/docs/cli/tools.mdx @@ -13,7 +13,7 @@ Every command below also accepts the [global options](/cli/commands#global-optio sim tools execute [options] ``` -Run one built-in tool and print what it produced (personal API key required) +Run one built-in tool and print what it produced (OAuth login or personal API key required) **Arguments** @@ -70,6 +70,6 @@ sim tools list [options] | `--oauth-provider ` | No | Restrict to tools that authenticate against this OAuth service. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `id`, `name`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | diff --git a/apps/docs/content/docs/cli/workflow-mcp-servers.mdx b/apps/docs/content/docs/cli/workflow-mcp-servers.mdx index ca7ca03a730..909fdf4a5db 100644 --- a/apps/docs/content/docs/cli/workflow-mcp-servers.mdx +++ b/apps/docs/content/docs/cli/workflow-mcp-servers.mdx @@ -13,7 +13,7 @@ Every command below also accepts the [global options](/cli/commands#global-optio sim workflow-mcp-servers create [options] ``` -Create Workflow MCP Server (personal API key required) +Create Workflow MCP Server (OAuth login or personal API key required) **Options** @@ -35,7 +35,7 @@ Create Workflow MCP Server (personal API key required) sim workflow-mcp-servers delete [options] ``` -Delete Workflow MCP Server (personal API key required) +Delete Workflow MCP Server (OAuth login or personal API key required) **Arguments** @@ -63,7 +63,7 @@ Delete Workflow MCP Server (personal API key required) sim workflow-mcp-servers tools create [options] ``` -Publish Workflow As MCP Tool (personal API key required) +Publish Workflow As MCP Tool (OAuth login or personal API key required) **Arguments** @@ -94,7 +94,7 @@ Publish Workflow As MCP Tool (personal API key required) sim workflow-mcp-servers tools list ``` -List Workflow MCP Tools (personal API key required) +List Workflow MCP Tools (OAuth login or personal API key required) **Arguments** @@ -112,7 +112,7 @@ List Workflow MCP Tools (personal API key required) sim workflow-mcp-servers tools delete [options] ``` -Unpublish Workflow MCP Tool (personal API key required) +Unpublish Workflow MCP Tool (OAuth login or personal API key required) **Arguments** @@ -141,7 +141,7 @@ Unpublish Workflow MCP Tool (personal API key required) sim workflow-mcp-servers get ``` -Get Workflow MCP Server (personal API key required) +Get Workflow MCP Server (OAuth login or personal API key required) **Arguments** @@ -159,7 +159,7 @@ Get Workflow MCP Server (personal API key required) sim workflow-mcp-servers list [options] ``` -List Workflow MCP Servers (personal API key required) +List Workflow MCP Servers (OAuth login or personal API key required) **Options** @@ -169,7 +169,7 @@ List Workflow MCP Servers (personal API key required) | --- | --- | --- | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | @@ -179,7 +179,7 @@ List Workflow MCP Servers (personal API key required) sim workflow-mcp-servers update [options] ``` -Update Workflow MCP Server (personal API key required) +Update Workflow MCP Server (OAuth login or personal API key required) **Arguments** diff --git a/apps/docs/content/docs/cli/workflows.mdx b/apps/docs/content/docs/cli/workflows.mdx index 7169b5b2d58..b1340cc251c 100644 --- a/apps/docs/content/docs/cli/workflows.mdx +++ b/apps/docs/content/docs/cli/workflows.mdx @@ -15,7 +15,7 @@ Every command below also accepts the [global options](/cli/commands#global-optio sim workflows activate create [options] ``` -Activate Workflow Version (personal API key required) +Activate Workflow Version (OAuth login or personal API key required) **Arguments** @@ -44,7 +44,7 @@ Activate Workflow Version (personal API key required) sim workflows operations apply [options] ``` -Apply Workflow Operations (personal API key required) +Apply Workflow Operations (OAuth login or personal API key required) **Arguments** @@ -177,6 +177,7 @@ sim workflows runs list [options] | `--start-date ` | No | Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | | `--end-date ` | No | Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | | `--order ` | No | Sort direction by run start time. This list is sortable only by run start time, so it takes `order` in place of `sortBy`/`sortOrder`, which it rejects. Accepted values: `asc`, `desc`. | @@ -371,7 +372,7 @@ sim workflows delete [options] sim workflows chat unpublish [options] ``` -Take a workflow’s chat deployment offline (personal API key required) +Take a workflow’s chat deployment offline (OAuth login or personal API key required) **Arguments** @@ -399,7 +400,7 @@ Take a workflow’s chat deployment offline (personal API key required) sim workflows chat status ``` -Show a workflow’s chat deployment (personal API key required) +Show a workflow’s chat deployment (OAuth login or personal API key required) **Arguments** @@ -417,7 +418,7 @@ Show a workflow’s chat deployment (personal API key required) sim workflows chat publish [options] ``` -Publish or replace a workflow’s chat deployment (personal API key required) +Publish or replace a workflow’s chat deployment (OAuth login or personal API key required) **Arguments** @@ -457,7 +458,7 @@ Publish or replace a workflow’s chat deployment (personal API key required) sim workflows deploy [options] ``` -Deploy Workflow (personal API key required) +Deploy Workflow (OAuth login or personal API key required) **Arguments** @@ -531,7 +532,7 @@ sim workflows run [options] | --- | --- | --- | | `--input ` | No | Trigger input as JSON (JSON, or @path / @- to read a file or stdin). | | `--async` | No | Queue the run and return immediately. | -| `--execution-timeout-seconds ` | No | Requested server-side timeout for an asynchronous run, in seconds. An upper bound, not the effective timeout: the run uses the smaller of this value and the plan's execution timeout, so requesting more than the plan allows silently yields the plan timeout. Rejected with `400` unless `async` is true. | +| `--execution-timeout-seconds ` | No | Maximum duration of an asynchronous run, in seconds, capped by the plan's execution timeout. Requires `async: true`; otherwise returns `400`. | | `--select-output ` | No | Return streamed outputs as blockName.path or childWorkflowId.blockName.path; selecting a child workflow applies to every invocation, requires --follow (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--include-file-base64` | No | Inline eligible output files as base64 content. Rejected when `async` is true. | | `--no-include-file-base64` | No | Send --include-file-base64 as false. | @@ -602,7 +603,7 @@ sim workflows deployment status sim workflows deployment update [options] ``` -Update Workflow Public API Access (personal API key required) +Update Workflow Public API Access (OAuth login or personal API key required) **Arguments** @@ -646,7 +647,7 @@ sim workflows state get sim workflows state replace [options] ``` -Replace Workflow State (personal API key required) +Replace Workflow State (OAuth login or personal API key required) **Arguments** @@ -715,6 +716,7 @@ sim workflows versions list [options] | Option | Required | Description | | --- | --- | --- | | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | @@ -781,7 +783,7 @@ sim workflows list [options] | `--folder ` | No | Folder path as shown in the app; the leading / is optional. | | `--deployed-only` | No | Return only workflows with an active deployment when true. | | `--no-deployed-only` | No | Send --deployed-only as false. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | | `--search ` | No | Case-insensitive substring match against the resource name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `position`, `name`, `createdAt`, `updatedAt`, `runCount`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | @@ -827,7 +829,7 @@ sim workflows restore sim workflows revert create [options] ``` -Revert Workflow To Version (personal API key required) +Revert Workflow To Version (OAuth login or personal API key required) **Arguments** @@ -856,7 +858,7 @@ Revert Workflow To Version (personal API key required) sim workflows rollback [options] ``` -Rollback Workflow (personal API key required) +Rollback Workflow (OAuth login or personal API key required) **Arguments** @@ -885,7 +887,7 @@ Rollback Workflow (personal API key required) sim workflows undeploy [options] ``` -Take a workflow out of deployment (personal API key required) +Take a workflow out of deployment (OAuth login or personal API key required) **Arguments** @@ -975,7 +977,7 @@ sim workflows ls [path] [options] | Option | Required | Description | | --- | --- | --- | | `--search ` | No | Filter folders and resources by name. | -| `--limit ` | No | Maximum combined items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum combined items to return (0 for everything). Defaults to `0`. | diff --git a/apps/docs/content/docs/cli/workspaces.mdx b/apps/docs/content/docs/cli/workspaces.mdx index 7876176f96b..d92c8eb3d26 100644 --- a/apps/docs/content/docs/cli/workspaces.mdx +++ b/apps/docs/content/docs/cli/workspaces.mdx @@ -27,7 +27,7 @@ sim workspaces members [options] | Option | Required | Description | | --- | --- | --- | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | @@ -45,6 +45,6 @@ sim workspaces list [options] | --- | --- | --- | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | diff --git a/apps/docs/content/docs/integrations/slack.mdx b/apps/docs/content/docs/integrations/slack.mdx index 47656dc9aa9..cad0e409c6b 100644 --- a/apps/docs/content/docs/integrations/slack.mdx +++ b/apps/docs/content/docs/integrations/slack.mdx @@ -956,7 +956,7 @@ Rename the Slack agent session associated with a thread. ### Slack List Channels -List accessible Slack conversations. Credential-group user tokens also return one-to-one and group direct messages. +List up to 10,000 accessible public and private Slack channels across as many cursor pages as Slack supplies, capped at 200 provider pages. #### Input @@ -964,16 +964,17 @@ List accessible Slack conversations. Credential-group user tokens also return on | --------- | ---- | -------- | ----------- | | `authMethod` | string | No | Authentication method: oauth or bot_token | | `botToken` | string | No | Bot token for Custom Bot | -| `includePrivate` | boolean | No | Include private channels the bot is a member of \(default: true\) | +| `includePrivate` | boolean | No | Include private channels the connected account can access \(default: true\) | | `excludeArchived` | boolean | No | Exclude archived channels \(default: true\) | -| `limit` | number | No | Maximum number of channels to return \(default: 100, max: 200\) | -| `cursor` | string | No | Pagination cursor from a previous response.next_cursor | +| `limit` | number | No | Conversations to request per Slack page \(default: 100, max: 200\) | +| `cursor` | string | No | Pagination cursor from a previous response.nextCursor to resume from | +| `maxPages` | number | No | Maximum number of Slack pages to fetch \(default: 200, max: 200\) | #### Output | Parameter | Type | Description | | --------- | ---- | ----------- | -| `channels` | array | Accessible public and private channels, plus direct and group DMs for credential-group user tokens | +| `channels` | array | Up to 10,000 accessible public and private channels | | ↳ `id` | string | Conversation ID \(for example, C123, D123, or G123\) | | ↳ `name` | string | Channel or group-DM name; omitted for one-to-one direct messages | | ↳ `is_channel` | boolean | Whether this is a channel | @@ -997,10 +998,12 @@ List accessible Slack conversations. Credential-group user tokens also return on | ↳ `is_user_deleted` | boolean | Whether the other participant in a direct message is deactivated | | ↳ `is_open` | boolean | Whether a direct or group-direct-message conversation is open | | ↳ `priority` | number | Slack sidebar sort priority | -| `ids` | array | Conversation IDs for every returned channel or DM | -| `names` | array | Names of returned channels and group DMs; one-to-one DMs have no name | -| `count` | number | Total number of conversations returned | -| `nextCursor` | string | Cursor for the next page; null if no more pages | +| `ids` | array | Conversation IDs for every returned channel | +| `names` | array | Names of returned channels | +| `count` | number | Total number of conversations returned across all fetched pages, up to 10,000 | +| `hasMore` | boolean | Whether more Slack conversation pages remain beyond the fetched window | +| `nextCursor` | string | Cursor to fetch the next page; null when there are no more pages | +| `pages` | number | Number of Slack conversation pages fetched in this invocation | ### Slack List Channel Members diff --git a/apps/docs/content/docs/knowledgebase/connectors.mdx b/apps/docs/content/docs/knowledgebase/connectors.mdx index d659d835704..7a9c3ac9235 100644 --- a/apps/docs/content/docs/knowledgebase/connectors.mdx +++ b/apps/docs/content/docs/knowledgebase/connectors.mdx @@ -8,6 +8,8 @@ import { Step, Steps } from 'fumadocs-ui/components/steps' import { Image } from '@/components/ui/image' import { FAQ } from '@/components/ui/faq' +For workspace Search with each person's source permissions, use the [Search connector guides](/search). This page covers connectors inside general knowledge bases. + Connectors continuously sync documents from external services into your knowledge base, so you never have to upload files manually. New content is added, changed content is re-processed, and deleted content is removed — all automatically. ## Available Connectors diff --git a/apps/docs/content/docs/meta.json b/apps/docs/content/docs/meta.json index fd86838d469..649510a1483 100644 --- a/apps/docs/content/docs/meta.json +++ b/apps/docs/content/docs/meta.json @@ -10,6 +10,7 @@ "workflows", "agents", "---Workspace---", + "search", "knowledgebase", "tables", "files", diff --git a/apps/docs/content/docs/platform/connected-accounts.mdx b/apps/docs/content/docs/platform/connected-accounts.mdx new file mode 100644 index 00000000000..1840650f209 --- /dev/null +++ b/apps/docs/content/docs/platform/connected-accounts.mdx @@ -0,0 +1,117 @@ +--- +title: Connected accounts +description: Collect accounts in one organization credential group and control which workspaces can use them. +--- + +import { Callout } from 'fumadocs-ui/components/callout' + +**Connected accounts** collects people's accounts in one shared pool for your organization, also called a **credential group**. An organization admin chooses the providers, invites people to connect, and allows specific workspaces to use the pool. Workflows in those workspaces can find an account by email through the [Credential block](/workflows/blocks/credential). + +Each organization has at most one pool. Creating it does not give any workspace access; the workspace allowlist starts empty. + +## Availability + +Connected accounts must be enabled for your organization. Sim Cloud also requires an active Enterprise plan. Organization owners and admins manage the pool, subject to the organization's permission settings. A workspace admin who is not an organization admin cannot change the pool or its workspace access. + +For self-hosted deployments using environment-based feature flags, set `CREDENTIAL_GROUPS=true`. Availability is organization-scoped; personal workspaces cannot use an organization pool. + +The setup instructions below describe **Settings → Connected accounts**, shown when Knowledge Member Access is disabled. When `KNOWLEDGE_MEMBER_ACCESS=true` and connected accounts is available, organization settings shows the Search **Integrations** page instead. Only the selected page is available, including through direct links. Switching pages does not remove existing connections or workspace access. + +## Set up connected accounts + +Open your organization’s **Settings → Connected accounts**. Select **Set up connected accounts** if this is your first time. + +The page has three tabs: + +| Tab | What you manage | +| --- | --- | +| **Providers** | Services people can connect and their shared configuration | +| **People** | Connection requests, each person's connected accounts, and revocation | +| **Workspace access** | Workspaces allowed to use the organization's accounts | + +### 1. Add providers + +In **Providers**, select **Add provider** and search the catalog. Complete any required configuration before adding the provider. Added providers appear in the list; use **Configure** to edit their settings or **Remove** in the row menu to remove them. + +| Provider | Organization setup | +| --- | --- | +| OAuth providers, such as Gmail or Google Drive | Add the provider from the available catalog. Each invited person authorizes their account. | +| Slack | Supply the Slack App ID, Slack workspace ID, OAuth client ID, and client secret, then complete app verification. The organization uses one app and Slack workspace. Existing workspace Slack bots remain separate. | +| Fireflies and Granola | Add the provider. Sim supplies the MCP endpoint and handles OAuth client registration. People only complete their own authorization. | +| Databricks | Enter a name, the tenant's MCP URL, a registered OAuth client ID, and a client secret if required by that client. | + +For Databricks, **Add** validates and saves the configuration before the provider appears in the list. Cancelling the form leaves nothing added. Use an official Databricks HTTPS MCP endpoint, such as `https://your-workspace.cloud.databricks.com/api/2.0/mcp/sql`, and the OAuth client registered for your deployment. People connecting later use this organization configuration. + +When editing Databricks, leaving the client secret blank preserves the saved secret. Changing the MCP URL or OAuth client requires people to reconnect. + +Adding a provider makes account connections available without enabling indexing. Search source setup is managed on **Settings → Integrations** when Search is enabled. Connected accounts has no indexing controls or status indicators. Managed MCP providers currently support live tool calls only. + +### 2. Invite people + +For Search-enabled organizations, open **Settings → Integrations → People**. Otherwise, use **Settings → Connected accounts → People**. Set up a provider for personal account connections before inviting people; approving a Search integration alone is not enough. + +1. Select **Request connections**. +2. Enter their email addresses and select **Send requests**. +3. Each person opens the invitation, signs in to Sim with the verified invitation email, and authorizes the providers they want to connect. +4. They select **Submit** to finish the connection form. + +Invitees can contribute accounts without joining your organization. The invitation grants access to their connection form; it does not grant access to your workspaces or workflows. + +Use the invitation email in workflow lookups. For example, if you invite `alex@example.com`, **Find Organization Account** with that email and **Gmail** finds Alex's active Gmail contribution. + +#### How the email is associated with a Sim user + +On first use, the invitation email must match the signed-in user's verified Sim email. Sim then binds the invitation to that user's account. Another person cannot take it over by opening the link, and a later email change does not transfer it to a different Sim user. The invitation email remains the workflow lookup key. + +OAuth account providers also verify that the provider email matches the invitation email. Managed MCP connections are associated with the verified Sim user who completes authorization; they do not independently verify the MCP provider's account email. Keep using the invitation email when looking up those connections. + +In **People**, open a person's actions menu and select **Resend** when they need another invitation. **Revoke** stops the organization from using that person's contributions. The person cannot undo an administrator's revocation by reconnecting on their own. + +### 3. Allow workspaces + +Open **Workspace access** and select **Add workspaces** to choose one or more workspaces. The list shows workspaces that have access; use a row's **Remove** action to withdraw it. Adding or removing a workspace applies immediately. Only workspaces in this organization can be allowed. + + +An allowed workspace gives every authorized manual and deployed workflow in that workspace access to **every active account in the pool**. This includes scheduled, webhook, and public deployments that pass their normal workflow authorization. There is no separate workflow allowlist or restriction to the running user's own contributions. + + +For example, allowing the Support workspace lets its authorized workflows use Alex's Gmail contribution even when someone else runs the workflow. Allowing the workspace does not grant anyone permission to edit or run a workflow they could not otherwise access. + +Removing a workspace stops subsequent use of the pool. It does not recall provider requests already in flight or remove data a workflow has already received. Chat continues to use each person's own connections; workspace access does not give Chat access to other people's accounts. + +## Use accounts in workflows + +Use the **Credential** block's organization operations in an allowed workspace: + +- **Find Organization Account** selects an OAuth account by invitation email and provider. +- **List Organization Accounts** returns a page of OAuth accounts, optionally filtered by email and providers. +- **Find Organization MCP Connection** selects a person's managed MCP connection by invitation email and MCP provider. +- **List Organization MCP Connections** returns a page of managed MCP connections, optionally filtered by email and provider. + +The organization is determined by the workflow's workspace. You do not enter a credential group ID or organization ID in the block. + +The outputs are account references, without tokens. Use an OAuth `credentialId` in the corresponding integration block's credential field. For managed MCP, `credentialId` identifies the person's connection; `mcpServerId` identifies shared configuration and cannot select that person's authorization by itself. + +See the [Credential block reference](/workflows/blocks/credential#organization-accounts) for inputs, outputs, pagination, and connection-event triggers. + +## Reconnect or stop sharing + +People can open **Settings → Account → Connected accounts** to view accounts they contributed, including contributions to organizations they have not joined. **Reconnect** starts authorization again. **Disconnect** stops the organization from using that account in subsequent calls. + +For administrators, the controls have different scopes: + +| Action | Effect | +| --- | --- | +| Remove a provider | Stops use of that provider's contributions in the pool | +| Revoke a person | Stops use of all that person's contributions to the organization | +| Remove a workspace from the allowlist | Stops that workspace's workflows from using the pool | + +## Current limits and troubleshooting + +- **One pool per organization:** there are no additional groups, per-workflow grants, or per-account workspace allowlists. +- **One Databricks configuration:** multiple tenant endpoints cannot be added as separate Databricks providers in the same pool. +- **Organization operations are missing:** confirm the feature is enabled for the organization and the workflow's workspace is allowed. +- **An invitation rejects your sign-in:** use the verified Sim account matching the invitation email. For OAuth providers, connect the provider account with that same email. +- **A Find operation fails:** it requires exactly one active matching connection. Check the invitation email, provider, connection status, and workspace access. It never chooses an arbitrary account when there are zero or multiple matches. +- **A list seems incomplete:** organization list operations return up to 100 connections per page. Follow `nextCursor` while `hasMore` is true. +- **A legacy Credential Group block fails:** replace it with the appropriate Credential block operation or trigger, update its output references, and redeploy the workflow. diff --git a/apps/docs/content/docs/platform/enterprise/access-control.mdx b/apps/docs/content/docs/platform/enterprise/access-control.mdx index 8914a18519f..bfe5964cde6 100644 --- a/apps/docs/content/docs/platform/enterprise/access-control.mdx +++ b/apps/docs/content/docs/platform/enterprise/access-control.mdx @@ -173,13 +173,14 @@ The **Chat Deployment** row also carries an **auth-mode allowlist** — *Auth mo | API Keys | Revokes workspace API keys. Members cannot list, create, or revoke one. | | Personal API Keys | Prevents members from using a personal API key against this workspace. | | Personal Credentials | Prevents connecting personal credentials, leaving only workspace-shared ones. | -| CLI Access | Prevents approving a CLI login, which mints a key for the public API. | +| CLI Access | Prevents approving a CLI login or using Sim CLI OAuth tokens for the public API. Existing API keys retain their own restrictions. | +| OAuth App Access | Prevents OAuth apps from accessing the group's workspaces. The organization default group also governs authorization, token issuance and refresh, and account-level billing and audit reads. | ##### Rows read from the organization default group Two rows — **Workspace Creation** and **Member Directory** — are read only from the organization's **default group**, because the act they govern names no workspace. On any other group the editor renders them inert, tags them **Organization**, and skips them in **Select All**. Set them on the default group. -Five more rows — **Integrations**, **API Keys**, **Invitations**, **Personal API Keys**, and **CLI Access** — apply on the group in front of you for anything scoped to one of its workspaces. The account-level path of the same action falls back to the default group: minting a personal key, an organization-wide invitation, an account-level CLI login. To close one of these completely, set it on the default group as well. +Six more rows — **Integrations**, **API Keys**, **Invitations**, **Personal API Keys**, **CLI Access**, and **OAuth App Access** — apply on the group in front of you for anything scoped to one of its workspaces. Account-level actions use the organization's default group, including minting a personal key, sending an organization-wide invitation, approving a CLI login, and authorizing or refreshing an OAuth app. ### 4. Choose who it applies to diff --git a/apps/docs/content/docs/platform/enterprise/meta.json b/apps/docs/content/docs/platform/enterprise/meta.json index 48e14db8d21..8b095acff3a 100644 --- a/apps/docs/content/docs/platform/enterprise/meta.json +++ b/apps/docs/content/docs/platform/enterprise/meta.json @@ -4,6 +4,7 @@ "index", "self-hosted", "sso", + "scim", "verified-domains", "session-policies", "access-control", diff --git a/apps/docs/content/docs/platform/enterprise/scim/entra.mdx b/apps/docs/content/docs/platform/enterprise/scim/entra.mdx new file mode 100644 index 00000000000..dac483eec86 --- /dev/null +++ b/apps/docs/content/docs/platform/enterprise/scim/entra.mdx @@ -0,0 +1,126 @@ +--- +title: Microsoft Entra provisioning +description: Connect Microsoft Entra ID to Sim and verify user and group provisioning +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Step, Steps } from 'fumadocs-ui/components/steps' + +Use a non-gallery enterprise application in Microsoft Entra ID to create, update, and deactivate Sim members. Configure [single sign-on](/platform/enterprise/sso) separately for authentication. + +## Before you start + +- An Enterprise organization in Sim, with an owner or administrator who can manage provisioning. +- Each user email domain [verified in Sim](/platform/enterprise/verified-domains). +- An Entra administrator who can create enterprise applications and manage provisioning. +- A Sim deployment reachable by Entra over HTTPS. Self-hosted deployments must also meet the [SCIM rollout requirements](/platform/enterprise/scim#deployment-and-upgrades). + +Automated user provisioning is available with Entra ID Free. Group provisioning and group-based application assignment require Entra ID P1 or higher. See [Microsoft's licensing comparison](https://learn.microsoft.com/en-us/entra/fundamentals/licensing). + +Start with dedicated test users. Provisioned members use a seat and receive workspace access only through a mapping or an existing grant. + +## Connect Entra to Sim + + + + +### Enable provisioning in Sim + +Open **Settings → Organization → Single sign-on → Provisioning** and turn on **Enable directory provisioning**. Copy the **Base URL** from **Connection**. Under **Tokens**, choose an expiry and select **Issue token**. Copy the token before closing its dialog; Sim shows it once. + + + +### Create the enterprise application + +In Microsoft Entra ID, open **Enterprise applications → New application → Create your own application**. Enter a name, such as **Sim provisioning**, select **Integrate any other application you don't find in the gallery (Non-gallery)**, and select **Create**. + + + + +### Configure the connection + +In the new application's **Provisioning** page, select **New configuration**. Choose **Bearer authentication** and enter: + +| Field | Value | +| --- | --- | +| Tenant URL | The HTTPS Base URL copied from Sim, ending in `/api/scim/v2` | +| Secret token | Your Sim token, without a `Bearer ` prefix | + +Select **Test connection**, then **Create** after the test succeeds. Entra adds the bearer prefix itself. A successful connection test verifies connectivity and authentication; continue with a test assignment to verify provisioning. + +![Entra provisioning connection settings with the deployment URL redacted and secret token masked](/static/enterprise/entra/connection.png) + + + These steps use Entra's current provisioning experience. In the legacy experience, choose **Automatic** provisioning, enter the same credentials under **Admin Credentials**, test the connection, and save. See [Microsoft's SCIM configuration guide](https://learn.microsoft.com/en-us/entra/identity/app-provisioning/use-scim-to-provision-users-and-groups). + + + + + +### Limit the scope and review mappings + +Under the provisioning configuration's **Properties**, keep **Scope** set to **Sync only assigned users and groups**. Review accidental deletion protection before broadening the rollout. + +In the application's **Users and groups**, select **Add user/group**, choose a test user, and select **Assign**. On the Free plan, assign users individually. + +Under **Attribute mapping**, review the user mappings. The defaults map `userPrincipalName` to `userName`, `mail` to the work email, and `displayName` to `displayName`. Use a valid email address from a verified Sim domain; if your UPN differs from the user's email, adjust the mappings before provisioning. Keep `userName` as the matching attribute. + +If **Mail** is empty, Entra omits the work email and Sim uses `userName` as the account email. That UPN must be a valid email address in a verified Sim domain. + +![Entra provisioning properties restricted to assigned users and groups](/static/enterprise/entra/scope.png) + + + + +## Verify a user lifecycle + +Open **Provision on demand**, search for the assigned test user, and select **Provision**. Review the import, scope, matching, and action results. In Sim, confirm that the member appears under **Organization → Members** and that **Single sign-on → Provisioning → Activity** shows successful requests. + +![Successful on-demand user provisioning in Entra with account details redacted](/static/enterprise/entra/provision-user.png) + +Change the test user's display name in Entra and provision them again. Confirm the new name in Sim. Repeating provisioning without changes should report that the source and target already match. + +Remove the test user's app assignment, then run on-demand provisioning for that user again. Confirm that Sim deactivates the existing member. Reassign the user and provision again to verify reactivation of the same account. Assignment changes can take a few minutes to become available to provisioning. + +Sim deactivation suspends access while retaining organization membership, ownership, and the seat. For users still managed by the provisioning job, soft deletion deactivates the Sim member and permanent deletion sends a SCIM `DELETE` that removes organization membership. Entra normally permanently deletes users 30 days after soft deletion, or sooner if an administrator purges them. After app unassignment, Entra stops managing the user and does not send a later directory deletion. See [Microsoft's deprovisioning lifecycle](https://learn.microsoft.com/en-us/entra/identity/app-provisioning/how-provisioning-works#deprovisioning) and [Sim's deactivation behavior](/platform/enterprise/scim#what-it-does). + + + Disabled or deleted Entra users cannot be selected for on-demand provisioning. [Start automatic provisioning](#start-automatic-provisioning) and complete the initial cycle before testing those changes through a scheduled cycle. See [Microsoft's on-demand limitations](https://learn.microsoft.com/en-us/entra/identity/app-provisioning/provision-on-demand). + + +## Provision groups and map access + +With Entra ID P1 or higher, create a dedicated security group with **Assigned** membership and add your test users as direct members. Assign that group to the enterprise application under **Users and groups**. Review the group mapping under **Attribute mapping** and keep group provisioning enabled. + +Run **Provision on demand** for the group and select its test members, or wait for a scheduled cycle. On-demand provisioning supports one group with up to five selected members at a time. Nested group membership is not supported; see [Microsoft's assignment guidance](https://learn.microsoft.com/en-us/entra/identity/enterprise-apps/assign-user-or-group-access-portal). + +Once the group appears under **Single sign-on → Provisioning → Group mappings** in Sim, map it to a workspace, permission group, or the organization admin role. Start with a workspace mapping and confirm that the test members gain access. + +Remove a test user from the group while keeping a separate direct app assignment. After the group membership sync, confirm that the directory workspace grant is withdrawn while the user remains an active organization member. Add them back and verify that access returns. Read [how Sim withdraws directory access](/platform/enterprise/scim#how-access-is-withdrawn) before mapping groups that overlap with existing manual access. + +## Start automatic provisioning + +After the test succeeds, return to the provisioning **Overview** and select **Start provisioning**. Wait for the initial cycle to complete and inspect **Provisioning logs** for failures. Entra normally runs subsequent cycles about every 40 minutes; changes are not immediate. See [Microsoft's provisioning lifecycle](https://learn.microsoft.com/en-us/entra/identity/app-provisioning/how-provisioning-works). + +Keep the scope limited to assigned users and groups as you add more members. Use **Provision on demand** for a small test; use the regular cycle to verify directory account disablement and deletion. + +During a scheduled lifecycle test, leave the job running after changing the test user. **Restart provisioning** clears the change watermark and begins a new initial cycle; changing mappings or scoping filters also resets it. Wait for the incremental cycle and inspect that user's provisioning log before making further changes. + +## Rotate the token + +Issue a replacement token in Sim while the current token remains active. In Entra, open the provisioning configuration's **Connectivity** page, replace **Secret token**, select **Test connection**, and save. Confirm a successful provisioning request before revoking the old token in Sim. Sim allows two active tokens so rotation can overlap. + +## Troubleshooting + +| Symptom | Check | +| --- | --- | +| Connection test fails | Enter only the token in **Secret token**. Check the complete HTTPS Tenant URL, token expiry, and whether provisioning is enabled in Sim. | +| User is out of scope | Assign the user to this enterprise application and check the provisioning scope and scoping filters. Allow time for assignment changes to propagate. | +| User creation fails | Verify the email domain in Sim, available seats, and whether the account belongs to another Sim organization. Review the mapped UPN and work email. | +| Repeat provisioning reports **Skipped** | Inspect the reason. **RedundantExport** means the source and target already match. | +| Directory disablement or deletion has not reached Sim | Check the user's scheduled provisioning log, the `IsSoftDeleted` mapping to `active`, enabled **Update** actions, and accidental deletion protection. Confirm **Skip out of scope deletions** is disabled. A successful connection test alone does not verify a lifecycle change. | +| Group assignment is unavailable | Check your Entra license. The Free plan supports individual user assignment but requires an upgrade for group assignment and provisioning. | +| Member has no workspace access | Provision the directory group and add a workspace mapping in Sim. User provisioning creates organization membership. | +| Disabled member still uses a seat | Sim suspends access on deactivation. Removing organization membership is a separate offboarding action. | + +Use Entra's **Provisioning logs** and Sim's **Provisioning → Activity** to inspect failures. Invalid or revoked tokens appear only in Entra because Sim cannot associate those requests with a connection. diff --git a/apps/docs/content/docs/platform/enterprise/scim/index.mdx b/apps/docs/content/docs/platform/enterprise/scim/index.mdx new file mode 100644 index 00000000000..fe540d080cb --- /dev/null +++ b/apps/docs/content/docs/platform/enterprise/scim/index.mdx @@ -0,0 +1,222 @@ +--- +title: Directory provisioning (SCIM) +description: Create, update, and deactivate Sim members automatically from your identity provider +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Step, Steps } from 'fumadocs-ui/components/steps' +import { Tab, Tabs } from 'fumadocs-ui/components/tabs' +import { Image } from '@/components/ui/image' +import { FAQ } from '@/components/ui/faq' + +Directory provisioning connects your identity provider to Sim over SCIM 2.0. Your provider creates members when someone joins, updates them when their details change, and deactivates them when it sends a deactivation request — without anyone touching Sim. + +It pairs with [SSO](/platform/enterprise/sso). SSO proves who someone is when they sign in. Directory provisioning decides who exists and what they can reach, before and after that. + + + Included with Enterprise plans. The settings are under **Single sign-on → Provisioning**. On self-hosted deployments, enable the SSO settings page to reach this tab. A saved SSO provider is not required for SCIM; [verify each email domain](/platform/enterprise/verified-domains) before provisioning users at that domain. Self-hosted deployments get it with the other enterprise features through `ENTERPRISE_ENABLED=true` and `NEXT_PUBLIC_ENTERPRISE_ENABLED=true`, or turn just this feature on or off with `SCIM_ENABLED` and `NEXT_PUBLIC_SCIM_ENABLED`, alongside the [SSO variables](/platform/enterprise/sso#self-hosted-setup). + + +## What it does + +| Your provider does this | Sim does this | +| --- | --- | +| Assigns a person to the Sim app | Creates or links their account; new organization members join as Members | +| Updates their name or email | Updates the Sim account, and ends their sessions if the address changed | +| Deactivates them | Blocks sign-in and stops their personal API keys. Everything they own, and every grant they hold, is left untouched; shared workspace keys keep working | +| Reactivates them | Lifts the suspension; current group memberships and mappings determine access | +| Sends a SCIM DELETE request | Removes their organization membership, ends their sessions, deletes their personal API keys, and reassigns what they owned | +| Adds them to a group | Grants whatever that group maps to | + +Deactivation preserves ownership, credentials, and workspace history. It also blocks scheduled runs and triggers acting as that person. Changes to group membership or mappings can still withdraw access while someone is suspended. + +## Turn it on + + + + +### Verify your domain + +Sim only provisions people whose email is in a domain your organization has verified. See [Verified domains](/platform/enterprise/verified-domains). + +This is what stops another tenant's directory from claiming an address it does not own. + + + +### Enable directory provisioning + +Open **Settings → Organization → Single sign-on → Provisioning** and turn on **Enable directory provisioning**. Sim shows your SCIM base URL: + +``` +https:///api/scim/v2 +``` + + + +### Issue a token + +In **Tokens**, choose whether the token expires (never, 90 days, or a year) and select **Issue token**. It appears once — copy it straight into your provider. + +Two tokens can be active at a time, so you can rotate without downtime: issue the new one, update your provider, confirm a sync succeeds, then revoke the old one. + + + +### Configure your provider + + + + +For a step-by-step walkthrough, see [Okta provisioning](/platform/enterprise/scim/okta). + +If you use OIDC for sign-in, create a separate provisioning integration: Okta cannot add SCIM to a custom OIDC app. In the Okta Integration Network catalog, add **SCIM 2.0 Test App (Header Auth)** for a private integration. See [Okta's setup guide](https://developer.okta.com/docs/guides/scim-provisioning-integration-connect/main/). + +In that app, open **Provisioning → Integration → Configure API Integration**, enable API integration, and enter: + +- **Base URL**: `https:///api/scim/v2` +- **API Token**: `Bearer ` (include `Bearer` and a space) + +Select **Test API Credentials**, then save. Under **Provisioning → To App**, enable Create Users, Update User Attributes, and Deactivate Users. Assign a test user first, then use **Push Groups** for groups you want to map in Sim. Keep assignment groups separate from groups you push, as required by Okta. + +For an existing custom SAML or SWA app instead, follow [Okta's custom-app SCIM guide](https://help.okta.com/en-us/Content/Topics/apps/apps_app_integration_wizard_scim.htm). Its SCIM connection fields differ: set the unique identifier to `userName`, choose HTTP Header authentication, and enable the relevant provisioning actions. + +Okta never deletes users over SCIM. Unassigning someone, or deactivating them in Okta, sends a deactivation — which Sim applies as a suspension. + + + + +For a step-by-step walkthrough, see [Microsoft Entra provisioning](/platform/enterprise/scim/entra). + +Create a non-gallery enterprise application. Open **Provisioning → New configuration** and choose **Bearer authentication**. In the legacy experience, set Provisioning Mode to **Automatic** instead. + +- **Tenant URL**: `https:///api/scim/v2` +- **Secret token**: your Sim token, without a `Bearer ` prefix + +Test the connection and create the configuration. Keep **Sync only assigned users and groups** as the scope, assign a test user, and verify them with **Provision on demand** before starting automatic provisioning. Group provisioning requires Entra ID P1 or higher; individual user provisioning is available on the Free plan. + +Entra runs an initial cycle over everyone in scope, then incremental cycles roughly every 40 minutes. Unassignment normally sends a deactivation. Soft-deleted directory users are retained for 30 days; hard deletion can then send a SCIM DELETE during a provisioning cycle. An administrator can hard-delete earlier, and users already unassigned may no longer be managed. See [Microsoft’s provisioning lifecycle](https://learn.microsoft.com/en-us/entra/identity/app-provisioning/how-provisioning-works). + + + + +Add **SCIM Provisioner with SAML (SCIM v2 Core)**. Use the SCIM 2.0 connector, since Sim does not support SCIM 1.1. + +- **SCIM Base URL**: `https:///api/scim/v2` +- **SCIM Bearer Token**: your Sim token + +In **Parameters**, map `scimusername` to the user's email. Check that **SCIM JSON Template** uses `urn:ietf:params:scim:schemas:core:2.0:User` and sends that email as `userName` or in `emails`. Save, enable the API connection, then enable provisioning. See [OneLogin's custom connector guide](https://onelogin.service-now.com/kb?id=kb_article_view&sysparm_article=KB0013904). + +Choose what happens when a user is deleted in OneLogin. **Suspend** maps to a Sim suspension; **Delete** removes their membership. If administrative approval is enabled, approve the pending provisioning actions in OneLogin before expecting a sync. See [OneLogin's provisioning test guide](https://developers.onelogin.com/docs/scim/test-your-scim/). + + + + +Open an application's **Provisioning** tab and configure a **Custom SCIM** integration. You can use an existing application, a custom SAML application, or a URL Bookmark for provisioning without SAML. + +- **Base URL**: `https:///api/scim/v2` +- **Token** (also called **Token Key**): your Sim token +- **Test User Email**: an unused address in a domain verified by your Sim organization + +Select **Test Connection**, enable group management if you plan to map groups, then select **Activate**. Activation creates and deletes a test user and, with group management enabled, a test group. See [JumpCloud's custom SCIM guide](https://jumpcloud.com/support/provision-and-manage-users-and-groups-in-apps-using-custom-scim-identity-management-integration). + + + + + + +### Map your groups + +Groups mean nothing to Sim until you say what they stand for. In **Single sign-on → Provisioning → Group mappings**, point each pushed group at one or more of: + +- a **permission group**, which governs models, integrations, and capabilities +- a **workspace**, at Read, Write, or Admin +- the **organization admin role** + +A group can carry several mappings. When two groups grant the same workspace at different levels, the stronger one wins. The organization's default permission group cannot be a target: it governs by having no members. + +Under **Provisioning rules**, select **Manage rules**. **Match permission groups by name** maps new or renamed directory groups to existing permission groups with the exact same name, including capitalization. After enabling it for groups already synced, select **Reconcile now** or wait for the next scheduled reconciliation. Sim creates no permission groups; renaming a directory group updates its automatic mapping and preserves mappings added manually. + +Mapping a permission group to a directory group switches that permission group to explicit membership permanently: it governs exactly the people in it, and an empty group governs nobody. A permission group that governed everyone in its workspaces stops doing so the moment it is mapped, so map groups you created for the directory rather than your organization-wide ones. + + + + + +Provisioning tab with an active SCIM connection, tokens, provisioning rules, group mappings, and recent activity + +## How access is withdrawn + +Sim records every grant it makes on your behalf. When someone leaves a group, what the directory granted is taken back. + +**Lock managed membership**, on by default for a new connection, makes the directory the source of truth for provisioned members: Sim refuses invitations, workspace grants, workspace role changes, and organization role changes for them, because the next sync would revert them anyway. Access a member already held by hand when a mapping started covering it counts as directory access from then on, so it is withdrawn with the mapping. Removals stay possible so an administrator can always act in an emergency. + +With locking off, access held before the directory mapping is preserved. If someone had manual Read access and the directory raises it to Admin, removing the mapping restores Read. A workspace role raised by hand above the directory's level is also left alone. The upgrade limitation below applies to grants recorded by older versions. + +## Provisioning and SSO together + +An active provisioned member can sign in once SSO is configured for their verified domain. Provisioning and SSO resolve to the same account. + +To stop SSO from creating organization memberships, open **Provisioning rules → Manage rules** and enable **Disable just-in-time provisioning**. While the SCIM connection is active and entitled, this overrides **Sign-in → First sign-in → Automatic**. Existing organization members can still sign in, including members added by invitation; the setting does not require every existing member to have been provisioned by SCIM. + +## Watching a sync + +Changes to the enable switch, provisioning rules, tokens, and mappings save immediately. There is no page-level Save button on this tab. + +**Single sign-on → Provisioning → Activity** lists recent authenticated requests with their status and, for a failure, what was wrong. Providers report a failed cycle without saying what they sent, so this is usually the fastest way to see the cause. A request that fails to authenticate has no connection to log against, so a wrong or revoked token shows up only as your provider's own authentication error. + +The scheduled reconciliation task re-applies group mappings hourly when the background task runner is configured and running. You can run it on demand with **Reconcile now**, which is also how a change to the connection settings reaches members before the next sync. + +## Deployment and upgrades + +Apply the database migrations before deploying the new application code. The SCIM tables and suspension fields are additive, so the older application can continue running against the expanded schema during rollout. + +Keep `SCIM_ENABLED=false` and `NEXT_PUBLIC_SCIM_ENABLED=false` on the new deployment until all older application instances and workers have been drained. Older versions do not enforce SCIM suspensions or explicit permission-group membership. Enable provisioning only after every instance runs the new code. These explicit flags also override the hosted default. + +Once SCIM has suspended users or established managed access, rolling back to code that predates SCIM is not safe: it cannot enforce those restrictions. Disabling the SCIM connection stops synchronization but does not undo suspensions or restore old membership semantics. + +Migration `0325_scim_manual_workspace_baseline` adds tracking of prior manual workspace access. It cannot reconstruct levels overwritten by older SCIM code. Review those existing directory-owned grants before withdrawing mappings with membership locking off, and restore prior manual access explicitly where needed. + +## Reference + +- Base URL: `https:///api/scim/v2` +- Authentication: `Authorization: Bearer ` +- Resources: `/Users`, `/Groups`, plus `/ServiceProviderConfig`, `/ResourceTypes`, and `/Schemas` +- Filters: `eq` only, up to ten terms joined with `and`. Users: `id`, `userName`, `externalId`, `emails.value` (also `emails[type eq "work"].value`), `active`. Groups: `id`, `displayName`, `externalId` +- Limits: sustained 1,500 requests per minute per connection with a burst capacity of 3,000, 1 MB per request, 5,000 members per group +- `userName` is stored and returned lower-cased. Unmodeled top-level attributes and custom schema extensions are retained, and a PUT preserves ones it omits. Passwords are neither stored nor returned; Sim controls `id`, `meta`, and group membership +- Group display names are unique within a connection, ignoring case +- Page size: up to 100 per request + + diff --git a/apps/docs/content/docs/platform/enterprise/scim/meta.json b/apps/docs/content/docs/platform/enterprise/scim/meta.json new file mode 100644 index 00000000000..85510bda6c6 --- /dev/null +++ b/apps/docs/content/docs/platform/enterprise/scim/meta.json @@ -0,0 +1,5 @@ +{ + "title": "Directory provisioning", + "pages": ["index", "okta", "entra"], + "defaultOpen": false +} diff --git a/apps/docs/content/docs/platform/enterprise/scim/okta.mdx b/apps/docs/content/docs/platform/enterprise/scim/okta.mdx new file mode 100644 index 00000000000..1bbba4218cb --- /dev/null +++ b/apps/docs/content/docs/platform/enterprise/scim/okta.mdx @@ -0,0 +1,108 @@ +--- +title: Okta provisioning +description: Connect a private Okta SCIM integration to Sim and verify user and group provisioning +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Step, Steps } from 'fumadocs-ui/components/steps' + +Use an Okta SCIM integration to create, update, and deactivate Sim members. This guide covers provisioning; configure [single sign-on](/platform/enterprise/sso) separately for authentication. + +## Before you start + +- An Enterprise organization in Sim, with an owner or administrator who can manage provisioning. +- Each user email domain [verified in Sim](/platform/enterprise/verified-domains). +- An Okta administrator account with access to application provisioning. +- A Sim deployment reachable by Okta over HTTPS. For self-hosted deployments, use its public URL and apply the [SCIM rollout requirements](/platform/enterprise/scim#deployment-and-upgrades). + +Start with a test user and a dedicated group before assigning your organization. Provisioned members use a seat; they receive workspace access only through a mapping or an existing grant. + +## Connect Okta to Sim + + + + +### Enable provisioning in Sim + +Open **Settings → Organization → Single sign-on → Provisioning** and turn on **Enable directory provisioning**. Copy the **Base URL** from **Connection**. Under **Tokens**, choose an expiry and select **Issue token**. Copy the token before closing its dialog; Sim shows it once. + + + +### Add the Okta integration + +In the Okta Admin Console, open **Applications and Resources → Applications → Browse App Catalog**. Search for **SCIM 2.0 Test App (Header Auth)** and select **Add Integration**. Give the app a recognizable name, such as **Sim provisioning**. + +For a provisioning-only app, hide its application icon from users and turn off automatic sign-in from the landing page. On **Sign-On Options**, select **Secure Web Authentication**, use your Sim HTTPS login URL, and set **Application username format** to **Email**. Complete the app setup. These template settings do not configure Sim SSO. + + + Okta cannot add SCIM to a custom OIDC app. Keep this provisioning app alongside your OIDC sign-in app. For an existing custom SAML or SWA app, use [Okta's custom-app SCIM instructions](https://help.okta.com/en-us/Content/Topics/apps/apps_app_integration_wizard_scim.htm); those connection fields differ from the catalog template below. + + + + +### Configure API integration + +In the new Okta app, open **Provisioning → Integration → Configure API Integration** and select **Enable API integration**. + +| Field | Value | +| --- | --- | +| Base URL | The URL copied from Sim, ending in `/api/scim/v2` | +| API Token | `Bearer ` — include `Bearer` and a space before the token | + +Select **Test API Credentials** and save after the test succeeds. A successful connection test checks connectivity and authentication; continue with a test assignment to verify provisioning. + +The Header Auth template sends this field as the complete `Authorization` header. Entering only the token produces **A bearer token is required**. + +![Okta API integration with a successful credential test; the test deployment URL is redacted](/static/enterprise/okta/api-integration.png) + + + +### Enable provisioning actions + +Under **Provisioning → To App**, select **Edit**, enable **Create Users**, **Update User Attributes**, and **Deactivate Users**, then save. Leave **Sync Password** disabled: Sim does not synchronize passwords through SCIM. + +Use an email address from a verified Sim domain for the application username and primary email. Review the attribute mappings if your Okta usernames differ from users' email addresses. + +![Okta provisioning actions with Create Users, Update User Attributes, and Deactivate Users enabled, and Sync Password disabled](/static/enterprise/okta/provisioning-actions.png) + + + + +This uses Okta's [private integration template](https://developer.okta.com/docs/guides/scim-provisioning-integration-connect/main/). It does not make the integration a published or certified Okta Integration Network application. + +## Verify a user lifecycle + +Assign one test user to the app from **Assignments**. In Sim, confirm that the account appears in the organization and that **Provisioning → Activity** shows successful requests. Update the user's **Display name** in Okta and confirm it in Sim. Sim uses the mapped `displayName`, falling back to the structured name when it is omitted. + +Unassign the test user and check that Sim marks them as deactivated. Reassign the same user and confirm that the existing member becomes active again. If the user belongs to a pushed group, remove them from that group and confirm the downstream removal **before** unassigning the app; see [Okta's offboarding order](https://help.okta.com/en-us/Content/Topics/users-groups-profiles/app-assignments-group-push.htm). + +Okta deactivates users over SCIM; it does not send a SCIM DELETE. Sim suspends access while retaining the user's organization membership, ownership, and seat. See [deactivation behavior](/platform/enterprise/scim#what-it-does) before using unassignment for offboarding. + +## Push groups and map access + +Use separate groups for app assignment and Group Push. Okta does not support using the same group for both purposes. Assign the users to the app first. Under **Push Groups → Find groups by name**, select the group, leave **Push group memberships immediately** enabled, and save with **Create Group** selected for a new downstream group. + +![Okta Group Push selecting an engineering group and creating its downstream group in Sim](/static/enterprise/okta/group-push.png) + +Once the group appears under **Single sign-on → Provisioning → Group mappings** in Sim, map it to a workspace, permission group, or the organization admin role. Workspace access requires a workspace mapping. If name matching is enabled, Sim can automatically map matching permission groups. + +Use a workspace mapping for the first test. Confirm that the member gains access, then remove them from the pushed group while keeping their app assignment. Confirm that the directory grant is withdrawn. Add them back and verify access is restored without creating another account. + +With **Lock managed membership** enabled, manage access for provisioned members through directory groups; manual invitations and grants are blocked. Read [how access is withdrawn](/platform/enterprise/scim#how-access-is-withdrawn) before mapping groups that overlap with existing manual access. + +## Rotate the token + +Issue a replacement token in Sim while the current token remains active. In Okta, edit **Provisioning → Integration** and replace **API Token** with `Bearer `. Test the credentials, save, and confirm a successful provisioning request before revoking the old token in Sim. Sim allows two active tokens so rotation can overlap. + +## Troubleshooting + +| Symptom | Check | +| --- | --- | +| Credential test fails | Include `Bearer ` before the token. Use the complete HTTPS base URL and a current Sim token, and confirm provisioning is enabled and reachable. | +| User creation fails | Confirm the email domain is verified, a seat is available, and the account does not belong to another Sim organization. | +| User exists but has no workspace access | Push the directory group and add a Sim workspace mapping. Assignment alone creates organization membership. | +| Name or membership changes do not arrive | Check the app assignment, provisioning actions, attribute mappings, and Group Push status in Okta. | +| A pushed group is missing members | Confirm those users are active in Okta and successfully assigned to the app. After activation, repush the group; see [Okta's Group Push troubleshooting](https://help.okta.com/en-us/Content/Topics/users-groups-profiles/usgp-group-push-troubleshoot.htm). | +| A deactivated member still uses a seat | Deactivation suspends access. Removing organization membership is a separate offboarding action. | + +Use **View Logs** in the Okta app and **Provisioning → Activity** in Sim to inspect failures. Requests with an invalid or revoked token appear only in Okta because Sim cannot associate them with a connection. diff --git a/apps/docs/content/docs/platform/enterprise/self-hosted.mdx b/apps/docs/content/docs/platform/enterprise/self-hosted.mdx index bd809138a9e..f256a162b28 100644 --- a/apps/docs/content/docs/platform/enterprise/self-hosted.mdx +++ b/apps/docs/content/docs/platform/enterprise/self-hosted.mdx @@ -48,6 +48,7 @@ Three features do not need a flag at all: **custom branding**, **session policie | Organizations | `ORGANIZATIONS_ENABLED` | `NEXT_PUBLIC_ORGANIZATIONS_ENABLED` | | Permission groups | `ACCESS_CONTROL_ENABLED` | `NEXT_PUBLIC_ACCESS_CONTROL_ENABLED` | | SAML and OIDC sign-in | `SSO_ENABLED` | `NEXT_PUBLIC_SSO_ENABLED` | +| Directory provisioning (SCIM) | `SCIM_ENABLED` | `NEXT_PUBLIC_SCIM_ENABLED` | | Custom branding — on by default | `WHITELABELING_ENABLED` | `NEXT_PUBLIC_WHITELABELING_ENABLED` | | Audit logs | `AUDIT_LOGS_ENABLED` | `NEXT_PUBLIC_AUDIT_LOGS_ENABLED` | | Usage tracking | `USAGE_MONITORING_ENABLED` | `NEXT_PUBLIC_USAGE_MONITORING_ENABLED` | @@ -71,7 +72,7 @@ See [Sandboxes](/platform/self-hosting/sandboxes) for the provider credentials, ## Schedule the background jobs -Two enterprise features are started by a cron-driven HTTP endpoint rather than by the app on its own schedule. What happens next differs: retention's cleanup runs inline in the app process, while each due data drain is handed to a background job. All four endpoints authenticate with a bearer token equal to `CRON_SECRET`, and all four return `401` when it is unset: +Data drains and retention are started by cron-driven HTTP endpoints rather than by the app on their own schedules. What happens next differs: retention's cleanup runs inline in the app process, while each due data drain is handed to a background job. Every endpoint authenticates with a bearer token equal to `CRON_SECRET` and returns `401` when it is unset: ```bash openssl rand -hex 32 @@ -85,9 +86,12 @@ Persist that value as `CRON_SECRET` on the app **and** on whatever calls these e | Retention — logs | `GET /api/logs/cleanup` | Daily | **No** — schedule it yourself | | Retention — soft deletes | `GET /api/cron/cleanup-soft-deletes` | Daily | **No** — schedule it yourself | | Retention — Chat tasks | `GET /api/cron/cleanup-tasks` | Daily | **No** — schedule it yourself | +| OAuth token cleanup | `GET /api/cron/cleanup-oauth-tokens` | Hourly | Yes — Helm and Docker Compose both call it | - Both shipped deployments schedule the data-drain dispatcher but **not** the three retention cleanup endpoints — neither the Helm chart nor Docker Compose's `cron` service. Setting `DATA_RETENTION_ENABLED=true` alone deletes nothing — the windows are evaluated only when one of those endpoints is called. Add them to `cronjobs.jobs` yourself, or drive them from an external scheduler. + Both shipped deployments schedule the data-drain dispatcher and OAuth token cleanup, but **not** the three configurable data-retention endpoints. Setting `DATA_RETENTION_ENABLED=true` alone deletes no retained product data — those windows are evaluated only when one of the three endpoints is called. Add them to `cronjobs.jobs` yourself, or drive them from an external scheduler. + + OAuth token cleanup runs independently of sign-in activity, removing expired and revoked credentials. See [Sign in with Sim](/platform/self-hosting/authentication#sign-in-with-sim) for provider configuration. ```bash diff --git a/apps/docs/content/docs/platform/enterprise/sso.mdx b/apps/docs/content/docs/platform/enterprise/sso.mdx index e520282b8a9..e5354181506 100644 --- a/apps/docs/content/docs/platform/enterprise/sso.mdx +++ b/apps/docs/content/docs/platform/enterprise/sso.mdx @@ -28,7 +28,15 @@ Decide your **Provider ID** before configuring your identity provider. It become ### 1. Open SSO settings -Go to **Settings → Organization → Single sign-on** in your organization settings. +Go to **Settings → Organization → Single sign-on**. The page has three tabs: + +| Tab | Manage | +| --- | --- | +| **Sign-in** | OIDC or SAML configuration, callback URLs, and first-sign-in membership | +| **Domains** | DNS verification shared by SSO and SCIM | +| **Provisioning** | SCIM connection, tokens, rules, group mappings, and activity | + +Use **Domains** to verify ownership, then return to **Sign-in** to configure your provider. Switching tabs preserves an unsaved sign-in draft while you stay on this page; use **Save** or **Update** to commit it. The selected tab is included in the URL, so it can be bookmarked or shared. On self-hosted deployments, Provisioning appears when SCIM is enabled. ### 2. Choose a protocol @@ -39,24 +47,24 @@ Go to **Settings → Organization → Single sign-on** in your organization sett ### 3. Fill in the form -Single Sign-On configuration form showing Provider Type (OIDC), Provider ID, Issuer URL, Domain, Client ID, Client Secret, Scopes, and Callback URL fields +Sign-in tab showing the OIDC configuration form with advanced options collapsed **Fields required for both protocols:** | Field | What to enter | |-------|--------------| -| **Provider ID** | A short slug identifying this connection. Letters, numbers, and dashes only. It must be **unique across every Sim organization**, so include something specific to you — `azure-ad-acme`, not `azure-ad`. If the ID is taken, Sim tells you and suggests a free one. | +| **Provider ID** | A short slug identifying this connection. Letters, numbers, and dashes only. It must be **unique across every Sim organization**, so include something specific to you, such as `azure-ad-acme`. If the ID is taken, Sim asks you to choose another. | | **Issuer URL** | The identity provider's issuer URL. Must be HTTPS. | | **Domain** | Your organization's email domain, e.g. `company.com`. Users with this domain will be routed through SSO at sign-in. | -| **Member provisioning** | **Automatic** adds a user authenticated through this verified SSO connection to the organization as a Member and consumes a billed seat. Team seat counts grow with membership; fixed-seat plans require available capacity. **Invite only** authenticates the user without creating organization membership. Neither mode grants workspace access automatically. | +| **First sign-in → On first SSO sign-in** | **Automatic** adds a user authenticated through this verified SSO connection to the organization as a Member and consumes a billed seat. Team seat counts grow with membership; fixed-seat plans require available capacity. **Invite only** authenticates the user without creating organization membership. Neither mode grants workspace access automatically. | **OIDC additional fields:** | Field | What to enter | |-------|--------------| | **Client ID** | The application client ID from your IdP. | -| **Client Secret** | The client secret from your IdP. | -| **Scopes** | Comma-separated OIDC scopes. Default: `openid,profile,email`. | +| **Client secret** | The client secret from your IdP. | +| **Scopes** | Under **Advanced options**. Comma-separated OIDC scopes; default: `openid,profile,email`. | For OIDC, Sim automatically fetches endpoints (`authorization_endpoint`, `token_endpoint`, `userinfo_endpoint`, `jwks_uri`) from your issuer's `/.well-known/openid-configuration` discovery document. You only need to provide the issuer URL. @@ -66,12 +74,12 @@ Go to **Settings → Organization → Single sign-on** in your organization sett | Field | What to enter | |-------|--------------| -| **Entry Point URL** | The IdP's SSO service URL where Sim sends authentication requests. | -| **Identity Provider Certificate** | The Base-64 encoded X.509 certificate from your IdP for verifying assertions. | +| **Entry point URL** | The IdP's SSO service URL where Sim sends authentication requests. | +| **Identity provider certificate** | The Base-64 encoded X.509 certificate from your IdP for verifying assertions. | -### 4. Copy the Callback URL +### 4. Copy the callback URL -The **Callback URL** shown in the form is the endpoint your identity provider must redirect users back to after authentication. Copy it and register it in your IdP before saving. +Copy **Callback URL** for OIDC or **ACS URL (Reply URL)** for SAML. This is the endpoint that receives your identity provider's authentication response. Register it in your IdP before saving. If you set a SAML **Callback URL override** under Advanced options, the copyable ACS URL uses that override. **OIDC providers** (Okta, Microsoft Entra ID, Google Workspace, Auth0): ``` @@ -89,6 +97,16 @@ Click **Save**. To test, sign out and use the **Sign in with SSO** button on the --- +## Editing and advanced configuration + +For a saved connection, open **Sign-in** and select **Edit**. The Provider ID remains fixed. A saved OIDC client secret appears as a mask with a suffix when available; **Replace** lets you enter a new secret, and **Keep saved** cancels that replacement. Select **Update** to save the provider, or **Discard** to abandon changes. + +**Advanced options** contains OIDC scopes and optional authorization, token, and JWKS endpoint overrides. For SAML, it contains Audience, Callback URL override, signed-assertion requirements, NameID format, and optional IdP metadata XML. **Attribute mapping** lets either protocol override the email, name, and stable user-ID claim names. Leave a mapping blank to use the protocol default. + +SCIM settings save immediately in the **Provisioning** tab. Its **Disable just-in-time provisioning** rule overrides Automatic first-sign-in membership while the connection is active and entitled. Existing members can still sign in. See [directory provisioning](/platform/enterprise/scim#provisioning-and-sso-together). + +Follow the [Okta](/platform/enterprise/scim/okta) or [Microsoft Entra](/platform/enterprise/scim/entra) provisioning walkthrough to connect a SCIM app and verify synchronization separately from sign-in. + ## Provider Guides @@ -107,7 +125,7 @@ Click **Save**. To test, sign out and use the **Sign in with SSO** button on the ``` 4. Under **Assignments**, grant access to the relevant users or groups 5. Copy the **Client ID** and **Client Secret** from the app's **General** tab -6. Your Okta domain is the hostname of your admin console, e.g. `dev-1234567.okta.com` +6. Copy your Okta organization domain from the account menu in the Admin Console, e.g. `dev-1234567.okta.com`. The Admin Console's `-admin` hostname is a different URL. See [Find your Okta domain](https://developer.okta.com/docs/guides/find-your-domain/main/). **In Sim:** @@ -115,12 +133,12 @@ Click **Save**. To test, sign out and use the **Sign in with SSO** button on the |-------|-------| | Provider Type | OIDC | | Provider ID | `okta` | -| Issuer URL | `https://dev-1234567.okta.com/oauth2/default` | +| Issuer URL | `https://dev-1234567.okta.com` | | Domain | `company.com` | | Client ID | From Okta app | | Client Secret | From Okta app | -The issuer URL uses Okta's default authorization server, which is pre-configured on every Okta org. If you created a custom authorization server, replace `default` with your server name. +For ordinary OIDC sign-in, use your Okta organization issuer as shown. A custom authorization server requires API Access Management; its issuer is `https:///oauth2/`. The `default` custom server is included in Okta's Integrator Free Plan but is not available in every production organization. See [Okta's authorization server guide](https://developer.okta.com/docs/concepts/auth-servers/). @@ -137,7 +155,7 @@ The issuer URL uses Okta's default authorization server, which is pre-configured ``` 3. After registration, go to **Certificates & secrets → New client secret** and copy the value immediately — it won't be shown again 4. Go to **Overview** and copy the **Application (client) ID** and **Directory (tenant) ID** -5. Go to **Token configuration → Add optional claim**, choose **ID**, and add **email**. Entra omits the email address for managed users without this claim, and sign-in then fails with a missing-user-info error +5. Keep `email` in Sim's OIDC scopes. On Entra's v2.0 endpoint, this scope requests the email claim; alternatively, add **email** under **Token configuration → Add optional claim → ID**. Confirm the account supplies an email in your verified domain: a user principal name is not necessarily that email, and the claim is not guaranteed for every account. See [Microsoft's ID token claims reference](https://learn.microsoft.com/en-us/entra/identity-platform/id-token-claims-reference) 6. If **Enterprise applications → Sim → Properties → Assignment required** is **Yes**, assign the users or groups who should sign in. Microsoft rejects unassigned users before they reach Sim **In Sim:** @@ -165,9 +183,9 @@ Use this when your tenant is configured for SAML rather than OIDC. Both are supp 2. Open **Single sign-on** and select **SAML** 3. Edit **Basic SAML Configuration** and set both values from Sim's SSO settings page: - **Identifier (Entity ID)** — the **SP Entity ID** field - - **Reply URL (Assertion Consumer Service URL)** — the **ACS URL** field + - **Reply URL (Assertion Consumer Service URL)** — the **ACS URL (Reply URL)** field 4. Under **Attributes & Claims**, confirm the default claims are present. Sim reads the standard schema claim URIs for email, name, and name identifier -5. Under **SAML Certificates**, download **Certificate (Base64)**. Its contents go in Sim's **Certificate** field, which is required. You can optionally also download **Federation Metadata XML** and paste it into Sim's **IDP Metadata XML** field under **Advanced Options** — it does not replace the certificate +5. Under **SAML Certificates**, download **Certificate (Base64)**. Its contents go in Sim's required **Identity provider certificate** field. You can optionally also download **Federation Metadata XML** and paste it into Sim's **IdP metadata XML** field under **Advanced options** — it does not replace the certificate 6. From the **Set up** panel for your application, copy the **Login URL** and the **Microsoft Entra Identifier** 7. Under **Users and groups**, assign the people who should be able to sign in — Microsoft rejects unassigned users before they reach Sim @@ -179,8 +197,8 @@ Use this when your tenant is configured for SAML rather than OIDC. Both are supp | Provider ID | `azure-ad-acme` (must be globally unique) | | Issuer URL | **Microsoft Entra Identifier**, e.g. `https://sts.windows.net/{tenant-id}/` | | Domain | `company.com` | -| Entry Point URL | **Login URL** from Entra | -| Certificate | Contents of the Base64 certificate | +| Entry point URL | **Login URL** from Entra | +| Identity provider certificate | Contents of the Base64 certificate | The **Identifier (Entity ID)** you set in Entra is what Sim validates the assertion's audience against. If it does not match the **SP Entity ID** shown in Sim exactly, sign-in fails with an audience mismatch. @@ -235,7 +253,7 @@ Use this when your tenant is configured for SAML rather than OIDC. Both are supp ``` https:///api/auth/sso/saml2/callback/adfs ``` -5. Export the **Token-signing certificate** from **Certificates**: right-click → **View Certificate → Details → Copy to File**, choose **Base-64 encoded X.509 (.CER)**. The `.cer` file is PEM-encoded — rename it to `.pem` before pasting its contents into Sim. +5. Export the **Token-signing certificate** from **Certificates**: right-click → **View Certificate → Details → Copy to File**, choose **Base-64 encoded X.509 (.CER)**. Paste the file's text into **Identity provider certificate**, including the certificate header and footer. 6. Note the **ADFS Federation Service endpoint URL** (e.g. `https://adfs.company.com/adfs/ls`) **In Sim:** @@ -246,8 +264,8 @@ Use this when your tenant is configured for SAML rather than OIDC. Both are supp | Provider ID | `adfs` | | Issuer URL | `https://adfs.company.com/adfs/services/trust` (the ADFS Federation Service identifier) | | Domain | `company.com` | -| Entry Point URL | `https://adfs.company.com/adfs/ls` | -| Certificate | Contents of the `.pem` file | +| Entry point URL | `https://adfs.company.com/adfs/ls` | +| Identity provider certificate | Contents of the exported Base64 certificate | The **Issuer URL** is the identity provider's own identifier, found in ADFS under **Service → Federation Service Properties → Federation Service identifier**. It is not Sim's URL — Sim's identifier is the **SP Entity ID** shown in the SSO settings, which you register in ADFS as the relying party identifier. @@ -269,16 +287,16 @@ Once SSO is configured, users with your domain (`company.com`) can sign in throu 2. They enter their work email (e.g. `alice@company.com`) 3. Sim redirects them to your identity provider 4. After authenticating, they are returned to Sim -5. If **Member provisioning** is **Automatic**, Sim adds them to the organization as a Member, growing a Team seat count or validating available fixed-seat capacity +5. If **First sign-in** is **Automatic**, Sim adds them to the organization as a Member, growing a Team seat count or validating available fixed-seat capacity 6. They land in an accessible workspace, or see a clear no-access state until an admin grants workspace access With **Automatic** provisioning, no invitation is required for organization membership. The join follows the organization's seat policy and does not infer a role from IdP claims: every newly provisioned user starts as a Member. Team subscriptions grow their billed seat count with membership; fixed-seat plans reject the join when capacity is full. With **Invite only**, SSO proves identity but does not create new membership or workspace access; new access must be granted separately, while existing organization membership and workspace access remain available. - Sign-in must start from Sim. Launching from your identity provider's app portal (Microsoft's **My Apps**, Okta's dashboard tile) sends an unsolicited assertion, which Sim rejects. This is deliberate — accepting them would let anyone replay an assertion into your tenant — but it means an IdP-initiated test fails even when the configuration is correct. + Start SAML sign-in from Sim's **Sign in with SSO** flow. Sim rejects unsolicited SAML assertions, so an IdP-initiated SAML test from an app portal can fail even when the configuration is correct. -SSO provisioning creates internal organization members but does not grant workspace access. External workspace members are different: they are invited to a specific workspace without joining your organization or consuming one of your seats. Existing invitations and external access take precedence over automatic provisioning so their intended role and workspace grants are preserved. +SSO provisioning creates internal organization members but does not grant workspace access. To grant workspace access from your identity provider, use [directory provisioning](/platform/enterprise/scim) and map a pushed group to a workspace. External workspace members are different: they are invited to a specific workspace without joining your organization or consuming one of your seats. Existing invitations and external access take precedence over automatic provisioning so their intended role and workspace grants are preserved. Password-based login remains available. Forcing all organization members to use SSO exclusively is not yet supported. @@ -301,11 +319,11 @@ SSO provisioning creates internal organization members but does not grant worksp }, { question: "What happens when a user signs in with SSO for the first time?", - answer: "Sim creates or links their account. If Member provisioning is Automatic and a seat is available, Sim adds them to your organization as a Member; no manual organization invite is needed. Workspace access is always granted separately. If provisioning is Invite only, or the user already has a pending invitation or external workspace access, Sim preserves that flow instead of creating membership automatically." + answer: "Sim creates or links their account. If first-sign-in provisioning is Automatic and a seat is available, Sim adds them to your organization as a Member; no manual organization invite is needed. Workspace access is always granted separately. If provisioning is Invite only, or the user already has a pending invitation or external workspace access, Sim preserves that flow instead of creating membership automatically." }, { question: "Does disabling someone in the identity provider remove their Sim access?", - answer: "No. Disabling the IdP account blocks future SSO authentication, but Sim does not currently receive SCIM deprovisioning or IdP logout events to remove membership or revoke active Sim sessions. Remove or suspend the user in Sim as part of offboarding." + answer: "When [directory provisioning](/platform/enterprise/scim) sends a deactivation, Sim blocks sign-in and personal API keys while preserving ownership. Shared workspace keys keep working. With SSO alone, disabling the IdP account only blocks future SSO authentication; remove the member in Sim as part of offboarding." }, { question: "Can I still use email/password login after enabling SSO?", @@ -313,7 +331,7 @@ SSO provisioning creates internal organization members but does not grant worksp }, { question: "A user already has an account with the same email — what happens when they sign in with SSO?", - answer: "Sim links the SSO identity to that account automatically. Linking is authorized by your verified domain: because you proved ownership of the domain before configuring SSO, Sim treats your identity provider as authoritative for email addresses on it. This works the same for OIDC and SAML, and does not depend on your IdP sending an email_verified claim — Microsoft Entra, for example, never sends one. Matching is by email address, so the address your IdP asserts must be identical to the one on the existing account. If it differs — a privileged or admin variant such as p-alice@company.com, or a different alias — Sim treats it as a new person and creates a separate account rather than linking." + answer: "Sim links the SSO identity to that account automatically. Linking is authorized by your verified domain: because you proved ownership of the domain before configuring SSO, Sim treats your identity provider as authoritative for email addresses on it. This works for OIDC and SAML without requiring an email_verified claim. Matching uses the address sent as email; Sim does not resolve different aliases or user principal names to an existing account. If the asserted address differs from the existing account, Sim treats it as a separate account." }, { question: "Who can configure SSO on Sim Cloud?", @@ -325,7 +343,7 @@ SSO provisioning creates internal organization members but does not grant worksp }, { question: "How do I update or replace an existing SSO configuration?", - answer: "Open Settings → Organization → Single sign-on and click Edit. Update the fields and save. The existing provider configuration is replaced." + answer: "Open Settings → Organization → Single sign-on → Sign-in and select Edit. Change the fields and select Update. The Provider ID cannot be changed; replacing it requires deleting the provider and creating a new one." } ]} /> @@ -342,6 +360,10 @@ Self-hosted deployments use environment variables instead of the billing/plan ch SSO_ENABLED=true NEXT_PUBLIC_SSO_ENABLED=true +# Optional: directory provisioning (SCIM), configured from Single sign-on → Provisioning +SCIM_ENABLED=true +NEXT_PUBLIC_SCIM_ENABLED=true + # Required if you want users auto-added to your organization on first SSO sign-in ORGANIZATIONS_ENABLED=true NEXT_PUBLIC_ORGANIZATIONS_ENABLED=true @@ -372,7 +394,7 @@ SSO_ENABLED=true \ NEXT_PUBLIC_APP_URL=https://your-instance.com \ SSO_PROVIDER_TYPE=oidc \ SSO_PROVIDER_ID=okta \ -SSO_ISSUER=https://dev-1234567.okta.com/oauth2/default \ +SSO_ISSUER=https://dev-1234567.okta.com \ SSO_DOMAIN=company.com \ SSO_USER_EMAIL=admin@company.com \ SSO_OIDC_CLIENT_ID=your-client-id \ diff --git a/apps/docs/content/docs/platform/enterprise/verified-domains.mdx b/apps/docs/content/docs/platform/enterprise/verified-domains.mdx index 4a4dedcd684..68df8d9618c 100644 --- a/apps/docs/content/docs/platform/enterprise/verified-domains.mdx +++ b/apps/docs/content/docs/platform/enterprise/verified-domains.mdx @@ -1,22 +1,25 @@ --- title: Verified Domains -description: Prove ownership of your email domains before configuring single sign-on +description: Prove ownership of your email domains for single sign-on and directory provisioning --- import { Callout } from 'fumadocs-ui/components/callout' +import { Image } from '@/components/ui/image' import { FAQ } from '@/components/ui/faq' -Verified Domains let organization owners and admins on Enterprise plans prove they control an email domain (like `acme.com`) with a DNS TXT record. Verifying a domain is the security precondition for configuring single sign-on for it. +Verified Domains let organization owners and admins on Enterprise plans prove they control an email domain (like `acme.com`) with a DNS TXT record. Verify a domain before configuring single sign-on for it or provisioning members at that domain through SCIM. - Configuring SSO for a domain requires it to be verified first. Verifying proves your organization controls the domain — without it, anyone could point another company's domain at their own identity provider. Domains you had already configured for SSO are automatically treated as verified. + Configuring SSO for a domain requires it to be verified first. Verifying proves your organization controls the domain — without it, anyone could point another company's domain at their own identity provider. The domain-verification migration preserved domains configured for SSO before this requirement was introduced. --- ## Verify a domain -Go to **Settings → Organization → Single sign-on** in your organization settings. Domains are managed in the **Verified domains** section at the top of that page, directly above the identity provider configuration. +Go to **Settings → Organization → Single sign-on → Domains**. The **Verified domains** section is shared by sign-in and directory provisioning. + +Domains tab showing a verified domain and the DNS record for a pending domain 1. Enter the domain, for example `acme.com`, and click **Add domain**. 2. Sim shows a DNS **TXT record** to publish — a host (`_sim-challenge.acme.com`) and a unique value (`sim-domain-verification=…`). @@ -24,10 +27,10 @@ Go to **Settings → Organization → Single sign-on** in your organization sett 4. Click **Verify**. Sim looks up the record; on success the domain is marked **Verified**. - Some DNS providers — GoDaddy, Namecheap, Hover, and most cPanel panels — append your zone to whatever you type in the host field. If yours does, enter the host with the trailing zone removed, or you will end up with `_sim-challenge.acme.com.acme.com` and verification will never succeed. If you manage the `acme.com` zone, enter `_sim-challenge`. To verify the subdomain `eng.acme.com` from that same zone, enter `_sim-challenge.eng`. Cloudflare and Route 53 take the full host as shown. + Some DNS providers append the zone automatically. If yours does, enter `_sim-challenge` when managing the `acme.com` zone, or `_sim-challenge.eng` to verify `eng.acme.com` from that zone. Check the resulting record name: it must match the full host Sim shows, without a repeated domain such as `_sim-challenge.acme.com.acme.com`. -DNS changes can take up to 48 hours to propagate — if verification does not succeed immediately, wait and retry. Leave the TXT record published — removing it can cause the domain to fail a later re-verification. +DNS changes can take time to propagate. If verification does not succeed immediately, check the record name and value, then wait and retry. Keep the TXT record published. Sim verifies it when you select **Verify**; removing the DNS record does not itself revoke a completed verification. Add each domain you own separately. Subdomains (`eng.acme.com`) are verified independently of the apex. @@ -40,12 +43,12 @@ Add each domain you own separately. Subdomains (`eng.acme.com`) are verified ind { question: 'Where does the TXT record go?', answer: - 'On a dedicated host, _sim-challenge., rather than the root of your domain — this avoids colliding with your SPF, DMARC, or other root TXT records.', + 'On the dedicated host _sim-challenge.. Add the supplied TXT value there without replacing existing records on your domain.', }, { question: 'What happens to domains we already use for SSO?', answer: - 'They are automatically treated as verified, so existing single sign-on keeps working with no action needed.', + 'Domains configured before domain verification was introduced were preserved as verified by the migration. New domains must complete DNS verification.', }, { question: 'Can two organizations verify the same domain?', @@ -55,7 +58,7 @@ Add each domain you own separately. Subdomains (`eng.acme.com`) are verified ind { question: 'What if I remove a verified domain?', answer: - 'You lose the ownership proof, so you cannot configure SSO for that domain until you re-add and re-verify it. Removing it does not sign anyone out — an already-configured SSO provider keeps working.', + 'New SSO sign-ins for that domain stop immediately, including through an existing provider. SCIM cannot create users or change an email to that domain until it is verified again. Removing the domain does not itself end existing sessions. Re-add and verify it to restore the ownership proof.', }, ]} /> @@ -73,4 +76,4 @@ NEXT_PUBLIC_SSO_ENABLED=true `ENTERPRISE_ENABLED` turns both on together, but it needs its own browser twin — set `NEXT_PUBLIC_ENTERPRISE_ENABLED` alongside it, or the server and the settings page enable SSO while the login page still hides its SSO entry point. See the [self-hosted enterprise guide](/platform/enterprise/self-hosted). -Once enabled, verify domains from **Settings → Organization → Single sign-on**, in the **Verified domains** section above the identity provider configuration. The older `/workspace//settings/domains` path still resolves to the same page. +Once enabled, verify domains from **Settings → Organization → Single sign-on → Domains**. The older `/workspace//settings/domains` path still resolves to the same page. diff --git a/apps/docs/content/docs/platform/meta.json b/apps/docs/content/docs/platform/meta.json index c4d59da4bdc..4e7ffeec952 100644 --- a/apps/docs/content/docs/platform/meta.json +++ b/apps/docs/content/docs/platform/meta.json @@ -1,4 +1,11 @@ { "title": "Platform", - "pages": ["workspaces", "organization", "permissions", "credentials", "costs"] + "pages": [ + "workspaces", + "organization", + "permissions", + "connected-accounts", + "credentials", + "costs" + ] } diff --git a/apps/docs/content/docs/platform/self-hosting/authentication.mdx b/apps/docs/content/docs/platform/self-hosting/authentication.mdx index 7af562b6bc2..c47590f6750 100644 --- a/apps/docs/content/docs/platform/self-hosting/authentication.mdx +++ b/apps/docs/content/docs/platform/self-hosting/authentication.mdx @@ -79,6 +79,64 @@ Providers are then registered in the app under **Settings → Organization → S See the [SSO guide](/platform/enterprise/sso) for identity-provider setup and the [self-hosted enterprise guide](/platform/enterprise/self-hosted) for the organization patterns. +## Sign in with Sim + +Your deployment acts as an OAuth 2.0 authorization server using authorization +code with PKCE. OAuth sign-in is available whenever authentication is enabled; +see [CLI authentication](/cli/authentication). + +Apply database migrations before deploying a new app version. When upgrading +from a version without the OAuth token-family lifecycle, drain older app +instances before accepting OAuth traffic so every instance enforces the same +refresh and revocation rules. + +`DISABLE_AUTH=true` disables OAuth sign-in and discovery because authorization +requires a real Better Auth user session. Older servers without OAuth support +return 404 from `/.well-known/oauth-authorization-server`, and the CLI falls +back to the pairing-code handoff. + +Access tokens are opaque and last an hour; refresh tokens rotate on every use. +Each login has a fixed thirty-day lifetime that refreshing does not extend. +Token validation checks current grants, so revoking a grant under +**Settings → General → Authorized apps** stops the app on its very next request. +Scheduled OAuth token cleanup runs independently of sign-in activity. + +Organization admins can restrict **OAuth App Access** under **Credentials & Access** +in [permission groups](/platform/enterprise/access-control). Workspace requests use +the group governing that workspace. Authorizing apps, issuing and refreshing tokens, +and account-level billing and audit reads use the organization's default group. +**CLI Access** also applies to the Sim CLI. Members can still review and revoke +existing grants when OAuth app access is restricted. + +### Registering an app + +Dynamic client registration is switched off, so clients are created by an +operator. Both `db:migrate` and the development `db:push` command install the +OAuth lifecycle triggers and register the Sim CLI. Repeating either command +preserves existing grants and client customizations. Register other apps with: + +```bash +DATABASE_URL=… \ +BETTER_AUTH_SECRET=… \ +OAUTH_CLIENT_ID=my-app \ +OAUTH_CLIENT_NAME="My App" \ +OAUTH_REDIRECT_URIS=https://my-app.example/callback \ +OAUTH_SCOPES=api:read \ +bun run apps/sim/scripts/create-oauth-client.ts +``` + +Add `OAUTH_CLIENT_PUBLIC=true` for a native or CLI app that cannot keep a +secret; it then authenticates with PKCE alone. The provider exposes +`offline_access`, `api:read`, and `api:write` for API authorization; it does not +expose OpenID Connect identity scopes or issue ID tokens. A confidential +client's secret is printed once and cannot be read back. Confidential clients +use `client_secret_basic`; the registration command prints the token +authentication method alongside the client ID. + +Redirect URIs must be `https`, or `http` on a loopback address, and are matched +exactly — except a loopback URI, where any port matches, because a native app +cannot know its port in advance. + ## Controlling who can sign up | Variable | Effect | diff --git a/apps/docs/content/docs/platform/self-hosting/background-jobs.mdx b/apps/docs/content/docs/platform/self-hosting/background-jobs.mdx index e5aa393b91c..5b8bfc8f435 100644 --- a/apps/docs/content/docs/platform/self-hosting/background-jobs.mdx +++ b/apps/docs/content/docs/platform/self-hosting/background-jobs.mdx @@ -49,8 +49,10 @@ Point cron at an **internal** address where possible (the in-cluster Service, or | Workspace file search dispatch | `/api/cron/workspace-file-search-dispatch` | `*/1 * * * *` | Dispatches indexing work for workspace file search | | Connector sync | `/api/knowledge/connectors/sync` | `*/5 * * * *` | Knowledge base connector syncs | | Connector member sync | `/api/knowledge/connectors/member-sync` | `*/5 * * * *` | Per-member access sync for permission-aware connectors | +| Connector directory sync | `/api/knowledge/connectors/directory-sync` | `*/5 * * * *` | Refreshes the directory groups administrator-mode connectors mirror, so a membership change takes effect without waiting for a content sync | | Workspace events poll | `/api/workspace-events/poll` | `*/15 * * * *` | Workspace event triggers | | Table row TTL cleanup | `/api/cron/cleanup-table-row-ttl` | `*/15 * * * *` | Deletes table rows whose TTL column has expired | +| OAuth token cleanup | `/api/cron/cleanup-oauth-tokens` | `0 * * * *` | Deletes access and refresh tokens after the retention tail | | Data drains | `/api/cron/run-data-drains` | `0 * * * *` | Enterprise data drains | | Renew subscriptions | `/api/cron/renew-subscriptions` | `0 */12 * * *` | Renews Microsoft Teams chat subscriptions (Graph caps them at ~3 days) | | Billing cycle close | `/api/cron/billing-cycle-close` | `0 */6 * * *` | Billing only — final overage collection and per-period tracker reset | diff --git a/apps/docs/content/docs/platform/self-hosting/environment-variables.mdx b/apps/docs/content/docs/platform/self-hosting/environment-variables.mdx index 8dbcf1ba31a..d424021946b 100644 --- a/apps/docs/content/docs/platform/self-hosting/environment-variables.mdx +++ b/apps/docs/content/docs/platform/self-hosting/environment-variables.mdx @@ -245,6 +245,7 @@ Enterprise features are unlocked by configuration rather than billing on self-ho |----------|-------------| | `ENTERPRISE_ENABLED`, `NEXT_PUBLIC_ENTERPRISE_ENABLED` | Enable the whole enterprise feature set | | `SSO_ENABLED`, `NEXT_PUBLIC_SSO_ENABLED` | Enable SAML and OIDC single sign-on on its own. See [Authentication](/platform/self-hosting/authentication#sso-saml-and-oidc) | +| `SCIM_ENABLED`, `NEXT_PUBLIC_SCIM_ENABLED` | Enable directory provisioning on its own. Needs SSO. See [Directory provisioning](/platform/enterprise/scim) | | `INSTANCE_ORG_NAME` | Name of the organization every user joins automatically at signup | | `INSTANCE_ORG_SLUG` | Slug for that organization (derived from the name when omitted) | | `INSTANCE_ORG_OWNER_EMAIL` | Owner of that organization (defaults to the first user to sign up) | @@ -311,7 +312,7 @@ Setting the variable to an empty string does **not** remove it: the chart reads Null the variable in every layer that sets it. If it appears in both `app.env` and `app.envDefaults`, nulling only the `app.env` entry lets the `envDefaults` value apply again and the limit stays in force. With External Secrets, also drop the key from `externalSecrets.remoteRefs.app`, which keeps syncing it independently. Confirm what the pod will actually receive before rolling out: ```bash -helm template sim ./helm/sim -f values.yaml | grep -A1 FREE_TABLE # expect no output +helm template sim oci://ghcr.io/simstudioai/charts/sim --version 1.9.5 -f values.yaml | grep -A1 FREE_TABLE # expect no output ``` `null` deletion has no effect under `helm upgrade --reuse-values` — pass your full values with `-f`, or use `--reset-then-reuse-values` (Helm 3.14+). If you deploy with Argo CD, put the `null` in `valueFiles` or the `values` string rather than `valuesObject`, which strips nulls. On Docker Compose, delete the line from your `.env` file. diff --git a/apps/docs/content/docs/platform/self-hosting/index.mdx b/apps/docs/content/docs/platform/self-hosting/index.mdx index 7cbfc77249b..4b6db96ecd2 100644 --- a/apps/docs/content/docs/platform/self-hosting/index.mdx +++ b/apps/docs/content/docs/platform/self-hosting/index.mdx @@ -68,7 +68,7 @@ Docker Compose and Kubernetes run the same application; what differs is the oper | Capability | Docker Compose | Kubernetes (Helm) | |---|---|---| | App, realtime, migrations, Postgres, Redis | Yes | Yes | -| Scheduled workflows and polling triggers | Yes — `cron` service | Yes — 22 CronJobs | +| Scheduled workflows and polling triggers | Yes — `cron` service | Yes — 23 CronJobs | | Horizontal scaling / HA | No (single node) | Yes (`replicaCount`, HPA, PDB) | | Managed secrets (Vault, ESO, cloud KMS) | Manual `.env` | Yes | | Network policy, Pod Security Standards | Host-level only | Yes | diff --git a/apps/docs/content/docs/platform/self-hosting/integrations-oauth.mdx b/apps/docs/content/docs/platform/self-hosting/integrations-oauth.mdx index fe9bc70bb4a..d8a11cc0e2a 100644 --- a/apps/docs/content/docs/platform/self-hosting/integrations-oauth.mdx +++ b/apps/docs/content/docs/platform/self-hosting/integrations-oauth.mdx @@ -114,6 +114,18 @@ One app registration in [Entra ID](https://entra.microsoft.com) covers all of th The same variables also power "Sign in with Microsoft". +### GitHub Search + +Register a [GitHub App](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/registering-a-github-app) with repository **Contents: read-only**, **Metadata: read-only**, and account **Email addresses: read-only** permissions. Keep user access token expiration enabled so Sim can rotate access and refresh tokens. + +| Environment variables | Provider ID | +|---|---| +| `GITHUB_APP_CLIENT_ID`
`GITHUB_APP_CLIENT_SECRET` | `github-repositories` | + +Register `https:///api/auth/oauth2/callback/github-repositories` as the callback. These App OAuth client credentials are separate from `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET` used for Sim sign-in. Sim does not require an App private key. + +A repository or organization administrator installs the App on the repositories to search. Each member connects their own GitHub account, with a verified email matching their Sim account. Search indexes repository files that both the member and the installed App can access. GitHub workflow blocks and existing knowledge-base token connections continue to use personal access tokens. + ### Everything else | Service | Environment variables | Provider ID | @@ -196,6 +208,8 @@ Webhook triggers receive callbacks from the provider and must be able to verify | `SLACK_SIGNING_SECRET` | Verifying Slack event and slash-command signatures | | `SLACK_EXTENDED_SCOPES` / `NEXT_PUBLIC_SLACK_EXTENDED_SCOPES` | Enabling the native Sim-app trigger and its broader Slack scope set; set both to the same value | +When enabling the native Sim Slack trigger, configure all three variables together. Enable the extended-scope flags only after Slack approves the app for `assistant:write`, `app_mentions:read`, and `im:history`; otherwise Slack rejects OAuth authorization. Slack OAuth actions can use `SLACK_CLIENT_ID` and `SLACK_CLIENT_SECRET` without enabling the native trigger or supplying a signing secret. + Your deployment must also be reachable from the provider's servers for webhook triggers to fire — a Sim instance on a private network can use polling triggers but not webhook triggers. Polling triggers additionally require the scheduler; see [Background Jobs](/platform/self-hosting/background-jobs). +## Helm repository + +For clusters or GitOps configs that consume `helm repo add` rather than OCI: + +```bash +helm repo add sim https://charts.sim.ai +helm repo update + +helm install sim sim/sim --version 1.9.5 --namespace simstudio --create-namespace \ + --set app.env.BETTER_AUTH_SECRET="$BETTER_AUTH_SECRET" \ + --set app.env.ENCRYPTION_KEY="$ENCRYPTION_KEY" \ + --set app.env.INTERNAL_API_SECRET="$INTERNAL_API_SECRET" \ + --set app.env.API_ENCRYPTION_KEY="$API_ENCRYPTION_KEY" \ + --set app.env.CRON_SECRET="$CRON_SECRET" \ + --set postgresql.auth.password="$POSTGRES_PASSWORD" +``` + +It serves the same chart as the OCI registry. The signature and provenance below +apply to the OCI artifact only. + +## Verifying the chart + +Every published version is signed with Sigstore keyless signing and carries a SLSA build-provenance attestation. Both live in the registry alongside the chart, so they survive a mirror into an internal registry. + +```bash +cosign verify oci://ghcr.io/simstudioai/charts/sim:1.9.5 \ + --certificate-identity-regexp '^https://github.com/simstudioai/sim/' \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com + +gh attestation verify oci://ghcr.io/simstudioai/charts/sim:1.9.5 --repo simstudioai/sim +``` + +Signing is Sigstore-only — there is no GPG `.prov` file, so `helm install --verify` does not apply. + + + Verification requires **cosign v3.0 or newer**. Signatures use the Sigstore protobuf bundle format, which cosign v3 writes by default and cosign v2 cannot read. cosign v3.1+ auto-detects both formats. + + ## Cloud-Specific Values These are cloud-tuned **alternatives** to the generic install above — pick one path, don't run both. The commands reuse the `$BETTER_AUTH_SECRET`, `$ENCRYPTION_KEY`, `$INTERNAL_API_SECRET`, `$API_ENCRYPTION_KEY`, `$CRON_SECRET`, and `$POSTGRES_PASSWORD` variables generated in [Installation](#installation) above, so run that block's `openssl` lines first in the same shell. They use `helm upgrade --install`, so they work whether or not a release exists yet. Two caveats when converting an existing generic install rather than starting fresh: (1) **reuse the original secret values** — recover them with `helm get values sim -n simstudio` if your shell no longer has them; supplying a newly generated `ENCRYPTION_KEY` makes every previously encrypted value (workspace environment variables, stored provider keys, MCP OAuth credentials) undecryptable. (2) The cloud values rename the bundled PostgreSQL database to `simstudio`, but Postgres only applies that setting on first initialization — add `--set postgresql.auth.database=sim` to keep your existing database. If you'd rather start clean, `helm uninstall sim -n simstudio`, delete its PVCs, and run the cloud command fresh. ```bash -helm upgrade --install sim ./helm/sim \ - --values ./helm/sim/examples/values-aws.yaml \ +# The example values files are not part of the packaged chart, so fetch the one +# you want at a release tag — pinning the chart but reading values off a moving +# branch would still make this command produce different deployments over time. +SIM_RELEASE=v0.8.24 +curl -fsSLO "https://raw.githubusercontent.com/simstudioai/sim/$SIM_RELEASE/helm/sim/examples/values-aws.yaml" + +helm upgrade --install sim oci://ghcr.io/simstudioai/charts/sim \ + --version 1.9.5 \ + --values values-aws.yaml \ --set app.env.BETTER_AUTH_SECRET="$BETTER_AUTH_SECRET" \ --set app.env.ENCRYPTION_KEY="$ENCRYPTION_KEY" \ --set app.env.INTERNAL_API_SECRET="$INTERNAL_API_SECRET" \ @@ -80,7 +131,7 @@ helm upgrade --install sim ./helm/sim \ Every one of those overrides is required. The cloud values files hardcode a placeholder domain in all six places, and overriding only `NEXT_PUBLIC_APP_URL` leaves sign-in pointed at the placeholder, realtime rejecting every socket upgrade, and the Ingress serving the wrong host. -Swap the `--values` file for your cloud: `values-aws.yaml` (EKS), `values-azure.yaml` (AKS), or `values-gcp.yaml` (GKE). Everything else is identical. +Swap the `--values` file for your cloud: `values-aws.yaml` (EKS), `values-azure.yaml` (AKS), or `values-gcp.yaml` (GKE). Everything else is identical. Keep the downloaded file in your own config repo — the `--set` overrides above cover the six placeholder domains, but anything else you tune belongs in the file. ## Key Configuration @@ -134,7 +185,7 @@ See `helm/sim/values.yaml` for all options, and the chart's [README](https://git ## Background jobs -The chart deploys 22 CronJobs by default, driving scheduled workflows, polling triggers, connector syncs, data drains, and outbox processing. They require `CRON_SECRET`; set `cronjobs.enabled=false` to deploy none. +The chart deploys 23 CronJobs by default, driving scheduled workflows, polling triggers, connector syncs, OAuth token cleanup, data drains, and outbox processing. They require `CRON_SECRET`; set `cronjobs.enabled=false` to deploy none. ```bash kubectl get cronjobs -n simstudio @@ -181,8 +232,8 @@ kubectl port-forward deployment/sim-app 3000:3000 -n simstudio # View logs kubectl logs -l app.kubernetes.io/component=app -n simstudio --tail=100 -# Upgrade -helm upgrade sim ./helm/sim --namespace simstudio +# Upgrade (always pin the target chart version) +helm upgrade sim oci://ghcr.io/simstudioai/charts/sim --version 1.9.5 --namespace simstudio # Uninstall helm uninstall sim --namespace simstudio @@ -191,4 +242,3 @@ helm uninstall sim --namespace simstudio - diff --git a/apps/docs/content/docs/platform/self-hosting/observability.mdx b/apps/docs/content/docs/platform/self-hosting/observability.mdx index 1e229fe1a69..04a496f27ad 100644 --- a/apps/docs/content/docs/platform/self-hosting/observability.mdx +++ b/apps/docs/content/docs/platform/self-hosting/observability.mdx @@ -78,16 +78,16 @@ app: See [Security](/platform/self-hosting/security) for the `INTERNAL_API_BASE_URL` requirement — the path fails closed without a cluster-reachable value. -## Anonymous telemetry +## Telemetry -Whether anonymous telemetry runs depends on how you deploy. A Docker Compose or source deployment sends it unless you turn it off, so a deployment with an egress policy should decide about this deliberately rather than inherit the default. +Whether telemetry runs depends on how you deploy. A Docker Compose or source deployment enables server telemetry unless you turn it off, so a deployment with an egress policy should decide about this deliberately rather than inherit the default. Telemetry has two halves that are decided at different times. | Half | When it is decided | Default | |---|---|---| | Server SDK | Runtime | **Off** on Helm (`app.envDefaults` sets `NEXT_TELEMETRY_DISABLED: "1"`); **on** for Docker Compose and source runs, which set nothing | -| Browser | `next build` | **Off** in every published image — the value is inlined at build time, and no runtime variable changes it | +| Browser | Server setting at page render, then browser preferences | Disabled when `NEXT_TELEMETRY_DISABLED=1`; otherwise waits for session resolution and the signed-in user's saved preference. Hosted Sim also requires the applicable analytics permission. | Neither half gates `POST /api/telemetry`, the relay that forwards browser events. See the warning below. @@ -95,7 +95,7 @@ When the server SDK runs it exports three OTLP signals to the same base endpoint The log stream is different in kind: it carries every line at or above `LOG_LEVEL` with its structured metadata, error messages and stack traces, which can include user ids, emails and URLs. That matters if you raise `LOG_LEVEL` as suggested above, and it is a reason to point the exporter at your own collector rather than leaving it at the default. -`NEXT_TELEMETRY_DISABLED=1` stops the server's OpenTelemetry SDK, which returns before it initializes, so it ends the app's own OTLP export to any endpoint. It does not affect Trigger.dev task telemetry, configured separately below. The **Settings → Privacy → Allow anonymous telemetry** toggle is browser-side only and does not stop server spans. +`NEXT_TELEMETRY_DISABLED=1` stops the server's OpenTelemetry SDK and disables browser collection on newly loaded pages. It does not affect Trigger.dev task telemetry, configured separately below. The **Settings → General → Privacy → Allow browser telemetry** toggle stops optional browser performance and error diagnostics, discards queued events, and remembers the choice. It does not stop server spans. On Helm, `NEXT_TELEMETRY_DISABLED` is what you must **clear** before any tracing works — including tracing to your own collector. Override it with `null` to remove the key entirely. diff --git a/apps/docs/content/docs/platform/self-hosting/reference-architectures.mdx b/apps/docs/content/docs/platform/self-hosting/reference-architectures.mdx index 0f3242e5995..8df1ba75150 100644 --- a/apps/docs/content/docs/platform/self-hosting/reference-architectures.mdx +++ b/apps/docs/content/docs/platform/self-hosting/reference-architectures.mdx @@ -139,7 +139,7 @@ See [Security](/platform/self-hosting/security) for the full secret inventory, w ## Calling the chart from Terraform -If you already run Terraform, the chart is the resource to wrap — not something to reimplement. It is not published to a Helm repository or an OCI registry, so there is no `repository` to point at: vendor this repo as a submodule, a release tarball, or a `git clone` in your pipeline, and give `chart` the local path. +If you already run Terraform, the chart is the resource to wrap — not something to reimplement. Point `repository` at the OCI registry and pin `version`; there is no need to vendor the repo. ```hcl resource "helm_release" "sim" { @@ -147,8 +147,15 @@ resource "helm_release" "sim" { namespace = "sim" create_namespace = true - # Local path, not a repository. Pin the git ref you vendor from. - chart = "${path.module}/sim/helm/sim" + # Always pin `version`. Without it Terraform resolves the newest published + # chart at apply time, which is how an unplanned apply moves Sim to a new + # release with new migrations. + repository = "oci://ghcr.io/simstudioai/charts" + chart = "sim" + version = "1.9.5" + + # Or the classic repository, if your tooling does not speak OCI: + # repository = "https://charts.sim.ai" # Your own values file. The examples under helm/sim/examples/ carry # placeholder secrets and are starting points, not deployable as-is. @@ -172,6 +179,6 @@ resource "helm_release" "sim" { The example values files ship literal placeholders such as `your-secure-production-auth-secret-here`. That includes `postgresql.auth.password`. The chart only rejects empty values and its own `CHANGE-ME` strings, so a deployment that inherits those placeholders installs cleanly with a publicly known session-signing secret and database password. Override every secret, or use External Secrets and set none of them inline. -Because the chart is local, `version` does nothing — what pins it is the git ref you vendor from, and `helm/sim/Chart.yaml` tells you which chart release that ref carries. Pin that ref, and pin the image tags separately, or an unplanned `terraform apply` can move Sim to a new release with new migrations. See [Upgrades](/platform/self-hosting/upgrades). +Pin `version` above, and pin the image tags separately — the chart version and the application version move independently, so pinning one does not pin the other. See [Upgrades](/platform/self-hosting/upgrades). Once the infrastructure exists, follow [Kubernetes](/platform/self-hosting/kubernetes) for the install itself, then the [pre-launch checklist](/platform/self-hosting/security) and the [verification checklist](/platform/self-hosting/verify). diff --git a/apps/docs/content/docs/platform/self-hosting/upgrades.mdx b/apps/docs/content/docs/platform/self-hosting/upgrades.mdx index 7fdf3f9bfe7..b5fde599612 100644 --- a/apps/docs/content/docs/platform/self-hosting/upgrades.mdx +++ b/apps/docs/content/docs/platform/self-hosting/upgrades.mdx @@ -156,7 +156,8 @@ Migration surprises are usually data-shaped rather than schema-shaped, so a stag ```bash -helm upgrade sim ./helm/sim \ +helm upgrade sim oci://ghcr.io/simstudioai/charts/sim \ + --version 1.9.5 \ --namespace simstudio \ --values my-values.yaml ``` @@ -164,7 +165,7 @@ helm upgrade sim ./helm/sim \ Preview first if the chart version changed: ```bash -helm diff upgrade sim ./helm/sim -n simstudio --values my-values.yaml +helm diff upgrade sim oci://ghcr.io/simstudioai/charts/sim --version 1.9.5 -n simstudio --values my-values.yaml ``` Then watch the rollout: diff --git a/apps/docs/content/docs/search/confluence.mdx b/apps/docs/content/docs/search/confluence.mdx new file mode 100644 index 00000000000..1937bddd578 --- /dev/null +++ b/apps/docs/content/docs/search/confluence.mdx @@ -0,0 +1,197 @@ +--- +title: Confluence +description: Connect Confluence Cloud spaces and set up each teammate's search access +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Step, Steps } from 'fumadocs-ui/components/steps' +import { Image } from '@/components/ui/image' + +Search pages and blog posts from selected Confluence Cloud spaces. A Sim organization admin configures the source, and each teammate connects their Confluence account. + +Search indexes each page's own text, including supported local callouts and code blocks. It does not expand Include Page, Excerpt Include, or third-party macros into that page. Referenced pages can be indexed separately with their own access rules. + +Admin setup uses your organization's **Settings → Integrations** page. Teammates connect from **Integrations** in the main sidebar. For workspace Search, use **Search → Add source** instead; **Create & Invite** is the workspace equivalent of **Add source**. + +## Choose a connection method + +| Method | Who supplies the content? | What teammates do | +| --- | --- | --- | +| **Admin or service account** | One account syncs content, space permissions, page restrictions, and group membership. | Connect their own Confluence account so Sim can match their Atlassian identity to those permissions. | +| **Member accounts** | Sim syncs content separately through connected members' accounts. | Connect their own Confluence account to establish which pages they can access. | + +Use **Admin or service account** when you have a dedicated account that can read the intended spaces and their permissions. Use **Member accounts** when each person should supply their own connection. Available methods depend on your organization's enabled features. + +**Everyone still connects in both methods.** With a central account, teammates supply their identity; they do not configure another central crawl or choose spaces again. + +## Before you start + +- Be a **Sim organization admin** to add the source. +- Use a Confluence Cloud site such as `your-team.atlassian.net`. This connector does not connect to Server or Data Center. +- Each teammate needs a verified Sim email matching their active Atlassian account's email. +- For a central crawl, grant its account access to Confluence, the chosen spaces, and any restricted pages you want indexed. Admin status alone does not bypass page restrictions. It also needs permission to read space permissions and the user/group directory. + +On hosted Sim, personal connections authorize the existing Sim app. Teammates do not create OAuth apps or service-account tokens. Self-hosted deployments need the [shared OAuth configuration](#self-hosted-operator-setup) even when a service account supplies the content. + +## Set up the source + + + + +### Choose Confluence + +Open **Settings → Integrations → Providers**, approve **Confluence**, then select **Set up**. Select your **Connection method**. + + + + +### Select an account + +For **Admin or service account**, open **Account** and select an existing account, choose **Connect Confluence account** for OAuth, or add a service account using the [steps below](#using-a-service-account). + +For **Member accounts**, **Browse with** supplies an account for the space picker only. Select or connect an account, or switch **Spaces** to manual input to enter space keys without a browsing account. Browsing does not connect that account to Search or share its access with teammates. + + + + +### Select the spaces + +Enter **Confluence Domain**, then choose one or more **Spaces**. The picker shows spaces accessible to the selected account. Use the switch beside the field to enter comma-separated **Space Keys**, such as `ENG, PRODUCT`. + +Keep **Content Type** at its default for pages, or choose blog posts or both. Leave **Filter by Label** empty unless you want a smaller scope. **Document details (optional)** contains metadata tag settings. + +Confluence Search source configuration showing connection method, account, domain, and spaces + + + + +### Save and connect your identity + +Click **Connect & Sync** for a central account, or **Add source** for member accounts. Back in Integrations, click **Connect account** on the Confluence row and finish the connection in the new tab. Sign in using the Atlassian email that matches your verified Sim email, and authorize the configured site. + +Each teammate completes this last step. A previously authorized account may already be connected. Return to Integrations to see indexing status and your searchable document count. + + + + +## Using a service account + +Sim's Atlassian service account form accepts a **scoped API token** and **site domain**. + + + + +### Give the service account Confluence access + +Have an Atlassian organization admin create a service account under **Directory → Service accounts** in [Atlassian Administration](https://admin.atlassian.com/). Give it Confluence access on the intended site. A space admin must also grant access to the chosen spaces and any restricted pages the source should index. See [Atlassian's service-account setup](https://support.atlassian.com/user-management/docs/manage-your-service-accounts/). + + + + +### Choose API token authentication + +Select the service account, then **Create credentials → API token → Next**. This is the credential type accepted by Sim's service-account form. + +Atlassian Administration authentication selector with API token selected + +Atlassian Administration's credential selector. See the [current Atlassian instructions](https://support.atlassian.com/user-management/docs/manage-api-tokens-for-service-accounts/). + + + + +### Select Confluence scopes + +Name the token and choose an expiry between 1 and 365 days. In the scope picker, choose **Confluence** and add the scopes below; the list includes both classic and granular scopes. Review and create the token, then copy it for the next step. Atlassian only reveals the token once. + +Use these scopes for Confluence Search content and permission reads: + +```text +read:confluence-content.all +read:page:confluence +read:blogpost:confluence +read:space:confluence +read:label:confluence +search:confluence +read:confluence-space.summary +read:content.metadata:confluence +read:space.permission:confluence +read:confluence-user +read:user:confluence +read:group:confluence +``` + + + + +### Add the token to Sim + +In the Search setup's **Account** menu, choose the service-account option. Paste the **API token** and enter **Site domain**. Optionally add a display name and description, then click **Add service account**. Continue in the original source modal, using the same domain in both forms. + + + + +Scopes do not grant access to spaces or pages by themselves. Keep the account's Confluence permissions and its token scopes aligned. When a token expires or needs different scopes, create a replacement in Atlassian. In Sim, open **Integrations**, select the saved service account, and click **Reconnect** to enter the new token and the same site domain. + + +Personal OAuth uses Sim's shared Confluence integration and requests a broader set of permissions, including writes. Search reads content and permissions; it does not edit your Confluence pages. Older OAuth connections need to reconnect to grant the group-read permission used by central permission syncing. + + +## Configuration + +| Setting | What it controls | +| --- | --- | +| **Confluence Domain** | The Cloud hostname, such as `your-team.atlassian.net`. Do not paste a page URL or `/wiki` path. | +| **Spaces / Space Keys** | Required spaces to index. The picker and manual key input are two ways to set the same scope. | +| **Content Type** | **Pages only** by default. **All content** means pages and blog posts; it does not include comments or attachment contents. | +| **Filter by Label** | Optional comma-separated labels. Content can match any listed label. | +| **Document details** | Optional labels, version, and last-modified metadata tags. | + +Search manages the schedule and hides item limits. Published/current content is indexed; archived and trashed content is excluded. + +## Teammates and ongoing sync + +Existing organization members see the configured Confluence source and their own **Connect account** or **Reconnect** action. Add new teammates through your Sim organization invitation or SSO onboarding, then have them connect Confluence from Integrations. Connecting a Confluence account does not add someone to the Sim organization. + +With a central account, Sim applies space access together with the page's restrictions and inherited ancestor restrictions. Group membership is refreshed in the background. With member accounts, each person's provider listing determines the pages available to them. A Sim organization admin does not automatically receive access to every Confluence document. + +New content and permission changes require a sync and processing before Search reflects them. Open **Manage** on the source to inspect errors, edit its configuration, or trigger a sync. If your own account needs authorization again, use **Reconnect** on the source row. + +## Troubleshooting + +| What you see | What to check | +| --- | --- | +| **Connect & Sync** is disabled | Select a central account, enter the domain, and choose at least one space. | +| Space picker is empty | Connect an account, enter the correct domain, and verify its space access. You can also switch to manual space keys. | +| Service-account validation fails | Check the token's expiry, site, Confluence app access, and scopes. Use a scoped API token from an Atlassian service account. | +| Content syncs but central search returns nothing | Connect your personal Confluence identity. Ask the admin to check directory/permission sync errors and group-read scopes. | +| A restricted page is missing | Ensure the crawling account can view that page and its ancestors, and that your own account has the required access. | +| Included or embedded content is missing | Add the referenced page's space to the source if appropriate. Search indexes pages separately; remote macro output, comments, and attachment contents are excluded. | +| **Reconnect** or an email mismatch | Reauthorize with the Atlassian account matching your verified Sim email and grant all requested permissions. | + +### Check access in Confluence + +Open a missing page in Confluence with the affected teammate's account. On the page, **Share → General access** shows whether access comes from the space, a parent, or an explicit restriction. A space admin can inspect restricted pages under **Space settings → Content → Restricted**. Check both the teammate and central crawling account when using **Admin or service account**. See Atlassian's [content access guide](https://support.atlassian.com/confluence-cloud/docs/add-or-remove-page-restrictions/). + +On Confluence Premium, **Inspect permissions** can show where a user's access is denied across the page, its ancestors, the space, and the product. Check **Can view**, resolve the relevant permission, then run a sync in Sim. See [Atlassian's permission inspection guide](https://support.atlassian.com/confluence-cloud/docs/inspect-a-users-permissions/). + +## Self-hosted operator setup + +Configure one shared Confluence OAuth integration for your deployment. This powers personal identity connections in both Search methods and the optional central OAuth account. + +1. In the [Atlassian developer console](https://developer.atlassian.com/console/myapps/), select or create your deployment's **OAuth 2.0 integration**. +2. Under **Authorization → OAuth 2.0 (3LO)**, add `https:///api/auth/oauth2/callback/confluence` to **Callback URLs**, keep existing callbacks used by the deployment, and save. +3. Under **Permissions**, add the Confluence API and configure the full `confluence` scope list for your release in [Sim's OAuth configuration](https://github.com/simstudioai/sim/blob/staging/apps/sim/lib/oauth/oauth.ts), including `read:group:confluence`. Also add **User Identity API** with `read:me`. Sim requests `offline_access` for refresh tokens. The service-account read scopes above do not replace the broader shared OAuth scope set. +4. Enable sharing under **Distribution**. Set `CONFLUENCE_CLIENT_ID` and `CONFLUENCE_CLIENT_SECRET` from the app's **Settings**, verify `NEXT_PUBLIC_APP_URL`, and restart Sim. +5. Start authorization from Search and select the configured site. Reconnect old accounts after adding scopes so the new permission grant takes effect. + +A callback mismatch needs a corrected callback URL; a connection that works only for the app owner needs sharing enabled. See Atlassian's [OAuth configuration guide](https://developer.atlassian.com/cloud/confluence/oauth-2-3lo-apps/) and Sim's [deployment reference](/platform/self-hosting/integrations-oauth). diff --git a/apps/docs/content/docs/search/connect-your-account.mdx b/apps/docs/content/docs/search/connect-your-account.mdx new file mode 100644 index 00000000000..aaadf503cad --- /dev/null +++ b/apps/docs/content/docs/search/connect-your-account.mdx @@ -0,0 +1,72 @@ +--- +title: Connect your account +description: Join your team's Search sources and finish connecting your own accounts +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Step, Steps } from 'fumadocs-ui/components/steps' +import { Image } from '@/components/ui/image' + +Your admin approves the provider and can configure shared source filters. You connect your own account so Sim can establish what you are allowed to search. + + + + +### Join your organization + +Accept your Sim organization invitation or sign in through your organization's SSO. Use a verified Sim email that matches your account at the source. Organization Search does not require workspace access. + + + + +### Open Integrations + +Open **Integrations**, find the provider or source, and select **Connect account**. Your first connection may ask for required source settings, such as repository or project names. If the provider is missing, ask an organization admin to approve it. + +Google Drive source row with Connect account + + + + +### Authorize your account + +In the new tab, select **Connect** and complete the provider's authorization. Choose the account associated with your verified Sim email. The provider may require your organization's SSO or app approval. + +Return to Integrations when the connection completes. If the popup was blocked or closed, allow popups and select **Connect account** again. While authorization is pending, use **Open again**. + + + + +### Start searching + +The source row shows indexing status and how many documents are available to you. Open **Search** in the organization sidebar and search for something you can already open in the source. Use **Home** to ask the assistant about your connected documents. The first sync may take time, especially for large accounts. + + + + +For a source configured inside a workspace, join that workspace and use its **Search** page instead. Organization and workspace sources are separate. + +## Do I always need to connect? + +| Source setup | Your next step | +| --- | --- | +| Member accounts | Connect your own account, including when you are the admin. | +| Confluence admin/service account | Connect Confluence to verify your identity; the administrator's account handles the crawl. | +| Google Drive delegated service account | No personal connection is needed for that source. Your verified Sim email is matched to Drive permissions. | +| GitLab instance administrator | No personal connection is needed. Your verified Sim email must match a confirmed GitLab email. | + +Connecting one Google service does not connect all of them. Gmail, Calendar, and Drive each have their own Search connection. + +## If you get stuck + +| Status | What to do | +| --- | --- | +| **Connect account** | Complete the connection in the new tab. | +| **Reconnect** | Authorize the same source account again. | +| **Finish connecting in the other tab** | Finish authorization, or use **Open again**. Allow popups for Sim. | +| No results | Check the source's filters and sync status with your admin. Confirm you can open the document at the source. | +| Needs admin attention | Ask your admin to inspect **Manage** for the source error. | + + + Your Sim role does not override document access at the source. Connecting a different account or receiving a Search link does not share someone else's mailbox, private calendar, or restricted documents with you. + diff --git a/apps/docs/content/docs/search/github.mdx b/apps/docs/content/docs/search/github.mdx new file mode 100644 index 00000000000..a6ca8d92fa2 --- /dev/null +++ b/apps/docs/content/docs/search/github.mdx @@ -0,0 +1,153 @@ +--- +title: GitHub +description: Search repository files through each member's GitHub account +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Step, Steps } from 'fumadocs-ui/components/steps' +import { Image } from '@/components/ui/image' + +GitHub Search indexes text files from a repository on `github.com`. An organization admin chooses the repository, then each person connects their GitHub account. Installing the GitHub App alone does not connect your teammates. + +Admin setup uses your organization's **Settings → Integrations** page. Teammates connect from **Integrations** in the main sidebar. For workspace Search, use **Search → Add source** instead; **Create & Invite** is the workspace equivalent of **Add source**. + +## Before you start + +Your Sim deployment needs a GitHub App configured as described below. The repository must contain at least one commit; initialize an empty repository with a README before adding it. For private repositories, an owner or administrator must install that App on them. Each person needs a verified GitHub email address matching their Sim account; the address can be private or secondary. + +## Configure the GitHub App + +This step belongs to the Sim deployment administrator. If the App is already configured, continue to [Add a repository](#add-a-repository). + + + + +### Register the App + +For a team, open **Your organizations → Settings** for the organization that will own the App. For a personal App, open your account's **Settings**. Then choose **Developer settings → GitHub Apps → New GitHub App**. + +Give the App a unique, recognizable name, such as **Your Company Sim Search**, and set **Homepage URL** to your Sim URL. + +Under **Identifying and authorizing users → Redirect URI (callback URL)**, enter: + +```text +https:///api/auth/oauth2/callback/github-repositories +``` + +| GitHub setting | Value for Sim Search | +|---|---| +| Allow wildcard matching | Disabled | +| Expire user authorization tokens | Enabled | +| Request user authorization (OAuth) during installation | Disabled | +| Enable Device Flow | Disabled | +| Post installation → Setup URL | Empty | +| Webhook → Active | Disabled | + +Authorization starts from Sim so the callback can finish the pending connection. The connector polls GitHub's API and does not need a webhook. + +GitHub App registration with the Redirect URI, expiring tokens enabled, and installation authorization, Device Flow, and webhooks disabled + +*Example registration. Replace `sim.example.com` with your Sim domain.* + + + + +### Set read permissions + +Expand **Permissions → Repository permissions**. Set **Contents → Access: Read-only**; leave the mandatory **Metadata** permission at **Read-only**. + +GitHub repository permissions with Contents set to Read-only and Metadata shown as mandatory Read-only + +Expand **Account permissions** and set **Email addresses → Access: Read-only**. + +GitHub account permissions with only Email addresses selected for Read-only access + +| Permission area | Permission | Access | +|---|---|---| +| Repository | Contents | Read-only | +| Repository | Metadata | Read-only | +| Account | Email addresses | Read-only | + +Leave every other permission at **No access**. Sim does not need issue, pull-request, administration, or write permissions. GitHub's [registration guide](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/registering-a-github-app) explains these settings. + +GitHub App user tokens use these permissions rather than OAuth scopes. An empty `scope` value in the token response is expected; see GitHub's [user token reference](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-user-access-token-for-a-github-app). + +Under **Where can this GitHub App be installed?**, choose **Only on this account** for an organization-owned App used only by members of that organization. Choose **Any account** when teammates or repository owners are outside that organization, or the App is owned by your personal account. A private App owned by a personal account can only be authorized by its owner; see GitHub's [App visibility rules](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/making-a-github-app-public-or-private). + +Select **Create GitHub App**. + + + + +### Configure Sim and install the App + +On the App's **General** settings page, copy its **Client ID**, then select **Generate a new client secret**. Configure these deployment variables and restart Sim: + +```text +GITHUB_APP_CLIENT_ID= +GITHUB_APP_CLIENT_SECRET= +``` + +Use the **Client ID** and **client secret** from **Developer settings → GitHub Apps**. OAuth App credentials used for GitHub sign-in are not compatible. The numeric **App ID** and downloaded private key are not used by this connector. Keep expiring user tokens enabled so Sim can [refresh them](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/refreshing-user-access-tokens). + +In the App's sidebar, choose **Install App**, select the target account, and grant access to the repositories you want to search. Return to Sim and select **Connect account**. Every teammate must authorize from Sim too. + + + + +## Add a repository + + + + +### Open GitHub setup + +As a Sim organization admin, open **Settings → Integrations → Providers**, approve **GitHub**, then select **Set up**. + + + + +### Choose what to index + +Enter the repository and keep **Sync documents with → Connected members** for the usual setup. + +| Field | What to enter | +|---|---| +| Repository | `owner/repo`. Add another source for another repository. | +| Branch | Optional. Leave blank to follow the repository's default branch. | +| Path Filter | Optional prefix such as `docs/`. | +| File Extensions | Optional comma-separated list, such as `.md, .txt, .mdx`. | + +**Document details** controls the metadata stored with results. Its defaults are suitable for most sources. Select **Add source** to save the source. + +You can instead select an existing account under **Sync documents with** to supply file contents centrally. Teammates still connect their own accounts to establish which files they may find. + + + + +### Connect your account + +On the GitHub source row, select **Connect account** and authorize the App. Teammates repeat this step after joining the Sim organization. For private repositories, both the person's account and the App installation must have access. GitHub also permits App user tokens to read public repositories without an installation; see [GitHub's permission rules](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/choosing-permissions-for-a-github-app). + +With **Connected members**, indexing begins after someone connects. A dedicated indexing account can start syncing immediately; each teammate still connects before searching. Admins can open **Manage** on the source to inspect sync progress and errors. + + + + +## Troubleshooting + +| Problem | Next step | +|---|---| +| GitHub is unavailable in Search | Ask the deployment admin to configure the App client credentials and enable member connections. | +| GitHub rejects `redirect_uri` | Register the exact callback on the GitHub App whose Client ID Sim uses: `http://localhost:3000/api/auth/oauth2/callback/github-repositories` for the default local server, or your production Sim origin followed by `/api/auth/oauth2/callback/github-repositories`. The scheme, host, port, and path must match; keep wildcard matching disabled. See [callback matching](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/about-the-user-authorization-callback-url). | +| Wrong app credentials | Copy the Client ID and client secret from **GitHub Apps**, not **OAuth Apps**. Set `GITHUB_APP_CLIENT_ID` and `GITHUB_APP_CLIENT_SECRET`, then restart Sim. A numeric App ID or private-key file cannot replace them. | +| Repository cannot be read | Confirm the App is installed on that repository and your GitHub account has access. For SAML organizations, establish your GitHub SSO session before reconnecting. | +| A teammate cannot authorize the App | Check **Where can this GitHub App be installed?** and the App owner. A private organization App accepts only organization members; a private personal App accepts only its owner. | +| Identity verification fails | Verify the email used by your Sim account in GitHub's email settings, then reconnect. A public profile email alone is insufficient. | +| Authorization fails after installation | Return to Sim and start **Connect account** there. Do not enable authorization during installation. | +| Sync is incomplete | Review the source status. Very large Git trees, file size limits, and unreadable files can limit indexing. | +| Empty repository returns an error | Add an initial commit, then sync again. GitHub does not return a file tree for an uninitialized repository. | + + + GitHub Search covers repository text files up to 100 MB, including symbolic links to files within the same repository. Path and extension filters apply to the link's path. Broken or external links, binaries, and submodules are not indexed. Issues, pull requests, separate wikis, GitHub Enterprise Server, and `ghe.com` domains are not supported by this connector. Personal access tokens remain available for general knowledge-base connectors, with that knowledge base's access rules. + diff --git a/apps/docs/content/docs/search/gitlab.mdx b/apps/docs/content/docs/search/gitlab.mdx new file mode 100644 index 00000000000..e8aeaa52d1a --- /dev/null +++ b/apps/docs/content/docs/search/gitlab.mdx @@ -0,0 +1,94 @@ +--- +title: GitLab +description: Index a self-managed GitLab project with its source permissions +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Step, Steps } from 'fumadocs-ui/components/steps' +import { Image } from '@/components/ui/image' + +GitLab Search uses an administrator connection to sync a project's content and permissions. Teammates do not connect individual GitLab accounts. They sign in to the Sim organization with a verified email matching their confirmed GitLab email. + +GitLab source setup in Sim Search + +Admin setup uses your organization's **Settings → Integrations** page. Teammates do not need a personal GitLab connection. For workspace Search, use **Search → Add source** instead. + +## Before you start + +Use a self-managed GitLab instance running version **17.4 or later**. Setup needs both a Sim organization admin and an active GitLab **instance administrator**. A project Maintainer or group Owner is insufficient. + + + This Search path requires GitLab's administrator directory and settings APIs. GitLab.com projects do not support this setup. General knowledge-base GitLab connectors can still use project-readable tokens, but their knowledge-base access rules are different from Search's source permissions. + + +## Add a project + + + + +### Create the administrator token + +Sign in to your self-managed GitLab instance as an instance administrator, then open **Avatar → Edit profile → Access → Personal access tokens**. On current releases choose **Generate token → Legacy token**; older versions show **Add new token** or the token form directly. + +Official GitLab example of the Access Tokens page with the Add new token button + +*Official [GitLab Handbook example](https://handbook.gitlab.com/handbook/security/product-security/security-platforms-architecture/product-security-engineering/runbooks/rotate-service-account-personal-access-tokens/). Navigation and button labels vary by version. The example's existing `api` tokens are unrelated to Sim; use the scopes below.* + +| Token setting | Value for Sim Search | +|---|---| +| Token name | A recognizable name, such as `Sim Search` | +| Expiration date | A date allowed by your instance's token policy | +| Scopes | `read_api`; also `admin_mode` if Admin Mode is enabled | + +Select **Generate token** or **Create personal access token**, then copy the value into Sim. GitLab only shows it once. This connector uses the traditional scoped PAT flow; do not substitute a project/group token or assume a fine-grained token has the required administrator API permissions. See GitLab's [current token creation steps](https://docs.gitlab.com/user/profile/personal_access_tokens/#create-a-personal-access-token). + +The token must read the project, users, inherited project membership, instance settings, and related group settings. Sim checks these before accepting source permission mirroring. See GitLab's [token scopes](https://docs.gitlab.com/security/tokens/access_token_scopes/). + + + + +### Configure the source in Sim + +Open **Settings → Integrations → Providers**, approve **GitLab**, then select **Set up**. Paste the token and enter your instance host explicitly. + +| Field | What to enter | +|---|---| +| Host | Your self-managed domain, such as `gitlab.example.com`. | +| Project | `group/project` or the numeric project ID. Add another source for another project. | +| Content | Defaults to **Wiki & Issues**. Choose **Code, Wiki, Issues & Merge Requests** to include all supported types. | +| Branch | Optional branch or tag for repository files; blank uses the project's default branch. | +| Path Filter / File Extensions | Optional limits for repository files. | +| Issue State / Labels / Milestone | Optional filters for issues. | +| Max Items | Optional positive limit. Leave blank for all matching items. | + +GitLab source configuration scrolled to repository and issue filters, item limit, and document details + +Select **Connect & Sync**. Sim validates the token and source policy, then starts indexing. + + + + +### Let teammates search + +Invite teammates to the Sim organization using their verified work email. Sim matches that email against the GitLab directory and applies project, feature, and confidential-issue permissions. No GitLab **Connect account** step is required. + +Admins can open **Manage** on the source to review sync progress. Permission and membership changes are picked up during background refreshes. + + + + +## What is indexed + +The connector supports text repository files, wiki pages, issues, merge requests, and non-internal issue and merge-request comments. It does not index internal comments, binaries, or epics. **Document details** controls optional result metadata. + +## Troubleshooting + +| Problem | Next step | +|---|---| +| Administrator token required | Use an active instance administrator's PAT with `read_api`, plus `admin_mode` when required. A project or group token cannot replace it. | +| Source permissions cannot be mirrored | Read the reported policy. Sim rejects unsupported external authorization, IP restrictions, download-ban policies, or session-specific step-up requirements. | +| Project not found | Check the host, project path or ID, and token access. | +| A teammate sees no results | Confirm both accounts' verified/confirmed email addresses match and the user has the required GitLab project or feature access. | +| Token expired | Remove and add the source again with a new token. This connector does not support replacing its token in place or refreshing PATs automatically. | + +Custom GitLab roles may grant more access than Sim's conservative role mapping recognizes. A source requiring unsupported policies must remain unavailable until its access model can be represented accurately. diff --git a/apps/docs/content/docs/search/gmail.mdx b/apps/docs/content/docs/search/gmail.mdx new file mode 100644 index 00000000000..107422340b6 --- /dev/null +++ b/apps/docs/content/docs/search/gmail.mdx @@ -0,0 +1,113 @@ +--- +title: Gmail +description: Connect each teammate's Gmail account to search their email in Sim +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Step, Steps } from 'fumadocs-ui/components/steps' +import { Image } from '@/components/ui/image' + +Search email threads from your own Gmail account. An organization admin enables the source; each teammate connects their own account. An admin's connection does not make their mailbox available to the team. + +Admin setup uses your organization's **Settings → Integrations** page. Teammates connect from **Integrations** in the main sidebar. For workspace Search, use **Search → Add source** instead; **Create & Invite** is the workspace equivalent of **Add source**. + +## Set up the source + +These steps require a Sim organization admin. + + + + +### Add Gmail + +Open **Settings → Integrations → Providers**, approve **Gmail**, then select **Set up**. Gmail uses **Member accounts**; there is no domain-wide or service-account crawl in Search. + + + + +### Choose what to include + +Keep the defaults to search all dates and labels, excluding Promotions, Social, Spam, and Trash. Add filters below if your team needs a narrower source. + + + + +### Create the source + +Click **Add source**. Gmail appears in the **Sources** list. Each person, including the admin, then connects their own account. + + + + +Gmail Search source configuration + +## Connect your account + +1. Join the Sim organization and verify your Sim email address. Open **Integrations** and click **Connect account** beside Gmail. +2. Complete the connection in the tab that opens. Choose the Google account whose verified email matches your Sim email, and grant the requested permissions. +3. Return to Integrations. The source shows its indexing status and the number of documents you can search. + +Teammates follow these same steps after joining the organization. Once an admin approves Gmail, the first connection can create its source with default filters. Admins can configure shared filters beforehand. + +## Source options + +An admin can change these under **Manage** on the Gmail source. Filters apply separately to each connected mailbox. + +| Option | Behavior | +| --- | --- | +| Labels | Optional comma-separated names or system IDs, such as `Engineering, INBOX`. A thread matching any listed label is included. Leave empty for all labels. Custom IDs such as `Label_7` belong to one mailbox and cannot be used for member setup. | +| Date Range | All time by default. Choose the last 7, 30, or 90 days, 6 months, or year. | +| Exclude Promotions / Exclude Social | Both enabled by default. Choose **No** to include either category. | +| Search Filter | Optional [Gmail query](https://developers.google.com/workspace/gmail/api/guides/filtering), such as `from:team@example.com subject:release`. This filters what is indexed; it is not a Sim Search query. | + +**Document details** contains optional metadata tags. Sync frequency and the general knowledge-base **Max Threads** setting are hidden in Search. + +## What gets indexed + +Sim indexes the message text Gmail returns for each matching thread, plus subjects, senders, dates, and labels. Filters select threads; messages within a selected thread are not filtered again. HTML email is converted to text. Results link back to Gmail. + +File attachments and image contents are not indexed. Thread discovery uses Gmail's default exclusion of Spam and Trash. A filter such as `has:attachment` selects the email thread; it does not index the attachment. Gmail API filtering also differs from Gmail's interface for aliases and thread-wide searches. See Google's [thread listing reference](https://developers.google.com/workspace/gmail/api/reference/rest/v1/users.threads/list) and [filtering guide](https://developers.google.com/workspace/gmail/api/guides/filtering). + +Search schedules syncs hourly. The first sync and large mailboxes can take longer; results appear as documents are indexed. Updates and removals are reconciled during background sync, rather than fetched live for each search. + +## Troubleshooting + +| What you see | What to do | +| --- | --- | +| A different email is requested | Use the Google account matching your verified Sim email. A separate personal account or alias does not satisfy the match. | +| No searchable documents | Check the source's labels, date range, category exclusions, and search filter. Allow the first sync to finish. | +| Finish connecting in the other tab | Complete the Google flow, or use **Open again** while authorization is pending. If the popup was blocked or closed, allow popups and select **Connect account** again. | +| Reconnect | Click **Reconnect** and authorize the same account again. | +| Unavailable or needs admin attention | Ask your Sim admin to check source status and the deployment's Google OAuth configuration. | + +## Self-hosted operator setup + +Users do not need to create Google Cloud credentials. The deployment operator configures one Google OAuth client for the instance: + +1. In [Google Cloud Console](https://console.cloud.google.com/), select your project. Open **APIs & Services → Library**, find **Gmail API**, and enable it. +2. Open **Google Auth platform → Branding**. Select **Get started** if needed, then enter the app name, support email, and contact email. Under **Audience**, use **Internal** only for an app limited to your Google Workspace organization; otherwise use **External** and add test users while testing. Review the app's permissions under **Data Access → Add or remove scopes**, using the current Sim scopes below. Follow Google's [consent and verification guidance](https://developers.google.com/workspace/guides/configure-oauth-consent) for your audience. +3. Open **Google Auth platform → Clients → Create client**. Choose **Web application**, give the client a name, and add the URI below under **Authorized redirect URIs**. If this instance already has a Google client, add this URI to that client instead. See [Google's credential setup](https://developers.google.com/workspace/guides/create-credentials#web-application). +4. Save the client ID and secret as `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`. Set `NEXT_PUBLIC_APP_URL` to the same Sim origin used in the callback, then restart Sim. See [Integrations & OAuth](/platform/self-hosting/integrations-oauth). + +```text +https:///api/auth/oauth2/callback/google-email +``` + +Google Cloud Web application client form with Sim's Gmail, Calendar, and Drive callback URLs + +This Google Cloud example uses one client for all three services. Replace `https://sim.example.com` with your Sim origin and add only the callbacks for services you enable. + +The current Sim Gmail connection uses these scopes: + +```text +openid +https://www.googleapis.com/auth/userinfo.email +https://www.googleapis.com/auth/userinfo.profile +https://www.googleapis.com/auth/gmail.modify +https://www.googleapis.com/auth/gmail.send +https://www.googleapis.com/auth/gmail.labels +``` + + + Google's `gmail.readonly` scope is sufficient for Search's email reads. Sim currently shares its Gmail OAuth connection with workflow actions and requires the broader scope set above; do not substitute `gmail.readonly` in this setup. Search does not send or modify email. See [Google's scope descriptions](https://developers.google.com/workspace/gmail/api/auth/scopes). + diff --git a/apps/docs/content/docs/search/google-calendar.mdx b/apps/docs/content/docs/search/google-calendar.mdx new file mode 100644 index 00000000000..4509563522f --- /dev/null +++ b/apps/docs/content/docs/search/google-calendar.mdx @@ -0,0 +1,118 @@ +--- +title: Google Calendar +description: Search calendar events using each teammate's own Google access +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Step, Steps } from 'fumadocs-ui/components/steps' +import { Image } from '@/components/ui/image' + +Search meetings and event details available to your Google account. An organization admin enables the source; every teammate connects their own account. Google controls which calendar and event details each person can read. + +Admin setup uses your organization's **Settings → Integrations** page. Teammates connect from **Integrations** in the main sidebar. For workspace Search, use **Search → Add source** instead; **Create & Invite** is the workspace equivalent of **Add source**. + +## Set up the source + +These steps require a Sim organization admin. + + + + +### Add Google Calendar + +Open **Settings → Integrations → Providers**, approve **Google Calendar**, then select **Set up**. Search uses **Member accounts**; an admin or service account cannot connect on behalf of everyone. + + + + +### Choose the calendars + +Leave **Calendars** empty to search each person's primary calendar. To include specific shared calendars, select them using **Browse with**, or switch to **Calendar IDs** and enter their IDs. + +**Browse with** only helps you choose calendars. It does not connect your account for Search or grant teammates access. + + + + +### Create the source + +Keep the default date range for the previous and next 30 days, then click **Add source**. Each person, including the admin, connects their own account from the **Sources** list. + + + + +Google Calendar Search source configuration + + + `primary` means the connected person's main calendar. A calendar selected from the list is a specific calendar ID, even when it is your main calendar. That same ID applies to every member, and only members with access to it can search its events. + + +## Connect your account + +1. Join the Sim organization and verify your Sim email. Open **Integrations** and click **Connect account** beside Google Calendar. +2. In the connection tab, choose the Google account whose verified email matches your Sim email. Grant the requested permissions. +3. Return to Integrations to see indexing status and your searchable document count. + +Teammates repeat only these connection steps after joining the organization. They do not need to configure the source. Connecting Gmail or Google Drive does not replace the Calendar connection. + +## Source options + +An admin can change these under **Manage** on the source. + +| Option | Behavior | +| --- | --- | +| Calendars / Calendar IDs | Empty defaults to each member's `primary` calendar. Explicit IDs restrict the source to those calendars. Multiple IDs are comma-separated; combine `primary` with shared calendar IDs if needed. | +| Date Range | Previous and next 30 days by default. Alternatives are the previous 30 days, next 30 days, or 90 days in each direction. The window moves forward on later syncs. | +| Search Query | Optional text filter applied by Google to event titles, descriptions, locations, and organizer or attendee names and emails. Leave empty to include all matching events in the date range. | +| Include Attendees | **Yes** by default. **No** omits organizer and attendee identity fields and keeps the attendee count. It does not redact names written into titles or descriptions. | + +**Document details** contains optional metadata tags. Search hides sync frequency and the general knowledge-base **Max Events** setting. + +## What gets indexed + +Sim indexes event titles, descriptions, times, locations, and the selected attendee information. All-day events and individual occurrences of recurring meetings are supported. Results link back to Google Calendar. + +Cancelled events, attachment contents, meeting recordings, and transcripts are not indexed. Events outside the selected date window are excluded. Private event details that Google withholds are not available in Search; see [Google's calendar sharing rules](https://developers.google.com/workspace/calendar/api/concepts/sharing). + +Search schedules syncs hourly. Event edits, cancellations, access changes, and events moving outside the date window are reconciled during background sync. The first sync may take longer, and results appear as indexing progresses. + +## Troubleshooting + +| What you see | What to do | +| --- | --- | +| No events | Check the date range, search query, and calendar IDs. Use an empty calendar selection or `primary` for each person's own calendar. | +| A shared calendar is missing | Confirm the connected Google account can read its events. Selecting a calendar in Sim does not share it in Google. | +| Busy times without event details | Google may expose only availability or hide private details. Ask the calendar owner to review sharing if more access is appropriate. | +| A different email is requested | Choose the Google account matching your verified Sim email. | +| Reconnect | Click **Reconnect** and complete Google authorization again. Allow pop-ups if the connection tab does not open. | +| Unavailable or needs admin attention | Ask your Sim admin to check source status and the deployment's Google OAuth configuration. | + +## Self-hosted operator setup + +The deployment operator configures Google OAuth once; teammates then use the normal connection flow. + +1. In [Google Cloud Console](https://console.cloud.google.com/), select your project. Open **APIs & Services → Library**, find **Google Calendar API**, and enable it. +2. Open **Google Auth platform → Branding** and configure the app name and contact details. Under **Audience**, choose **Internal** for your Google Workspace organization only, or **External** for other users. Add test users while an external app is testing. Review **Data Access → Add or remove scopes** using the current Sim scopes below. See Google's [consent and verification guidance](https://developers.google.com/workspace/guides/configure-oauth-consent). +3. Open **Google Auth platform → Clients → Create client**, choose **Web application**, and add the URI below under **Authorized redirect URIs**. Add it to the existing Google client if the instance already uses one. See [Google's credential setup](https://developers.google.com/workspace/guides/create-credentials#web-application). +4. Save the client ID and secret as `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`. Set `NEXT_PUBLIC_APP_URL` to the same Sim origin used in the callback, then restart Sim. See [Integrations & OAuth](/platform/self-hosting/integrations-oauth). + +```text +https:///api/auth/oauth2/callback/google-calendar +``` + +Google Cloud Web application client form with Sim's Gmail, Calendar, and Drive callback URLs + +This Google Cloud example uses one client for all three services. Replace `https://sim.example.com` with your Sim origin and add only the callbacks for services you enable. + +The current Sim Calendar connection uses these scopes: + +```text +openid +https://www.googleapis.com/auth/userinfo.email +https://www.googleapis.com/auth/userinfo.profile +https://www.googleapis.com/auth/calendar +``` + + + Search's reads can use `calendar.events.readonly`, `calendar.calendarlist.readonly`, and `calendar.calendars.readonly` for events, the calendar list, and calendar details. Sim currently shares its Calendar OAuth connection with workflow actions and requires the broader `calendar` scope above. Do not replace it with read-only scopes in this setup. Search does not change calendars or events. See [Google's scope descriptions](https://developers.google.com/workspace/calendar/api/auth). + diff --git a/apps/docs/content/docs/search/google-drive.mdx b/apps/docs/content/docs/search/google-drive.mdx new file mode 100644 index 00000000000..0918de1e56c --- /dev/null +++ b/apps/docs/content/docs/search/google-drive.mdx @@ -0,0 +1,165 @@ +--- +title: Google Drive +description: Connect Drive files through member accounts or a delegated service account +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Step, Steps } from 'fumadocs-ui/components/steps' +import { Image } from '@/components/ui/image' + +Search Google Docs, Sheets, Slides, and supported files in Drive. A Sim organization admin chooses the folders and connection method once. + +Admin setup uses your organization's **Settings → Integrations** page. Teammates connect from **Integrations** in the main sidebar. For workspace Search, use **Search → Add source** instead; **Create & Invite** is the workspace equivalent of **Add source**. + +## Choose your setup + +| Method | Use it when | What teammates do | +| --- | --- | --- | +| **Member accounts** | Each person should connect their own Drive access. No Google Workspace administrator setup is needed. | Connect their own Google Drive accounts after the source is created. | +| **Service account** | A Google Workspace administrator can configure delegation and directory access for a central crawl. | Sign in to Sim with matching verified email addresses; no personal Drive connection is needed for this source. | + + + A central crawl indexes only files the configured **Crawl as** account can access. Domain-wide delegation does not make this connector crawl every employee's Drive. Share the intended content with the indexing account, or use member accounts for each person's accessible files. + + +## Set up member accounts + + + + +### Add Google Drive + +Open **Settings → Integrations → Providers**, approve **Google Drive**, then select **Set up** and choose **Member accounts**. + + + + +### Choose the files + +Leave **Folders** empty to include supported files each connected member can access, or select folders to narrow the source. **Browse with** helps you pick folders; you can also switch to **Folder IDs** and enter comma-separated IDs from their Drive URLs. + +Keep **Sync documents with → Connected members** unless you have a dedicated indexing account. Selecting an indexing account does not replace each person's access verification. If that indexing account is a delegated service account, use **Crawl as** to choose the Google Workspace user whose files it should fetch. + + + + +### Create and connect + +Select **Add source**, then **Connect account** on the source row. Use the Google account matching your verified Sim email. Teammates follow the same [connection steps](/search/connect-your-account) after joining the organization. + + + + +## Set up a central service account + +This requires a Google Workspace domain and a Workspace super administrator to authorize domain-wide delegation. Consumer Gmail accounts cannot use this path. + +Google Drive Search service-account connection and source options + + + + +### Prepare the service account + +In [Google Cloud Console](https://console.cloud.google.com/), select your project and enable **Google Drive API** and **Admin SDK API** under **APIs & Services → Library**. Then open **IAM & Admin → Service Accounts → Create service account**, enter a name, and finish creation. Google Cloud project roles do not grant access to Workspace files; they are not required for this crawl. + +Google Cloud Console form for creating a service account + +Open the service account's **Keys** tab and choose **Add key → Create new key → JSON**, then select **Create** to download the key. Store it securely; you will add it to Sim next. See [Google's key creation guide](https://docs.cloud.google.com/iam/docs/keys-create-delete#creating). + +Google Cloud Create private key dialog with JSON selected + + + + +### Authorize domain-wide delegation + +In the service account's **Details**, expand **Advanced settings** and copy its numeric **Client ID**. Sign in to the [Workspace Admin Console](https://admin.google.com/ac/owl/domainwidedelegation) as a super administrator. Open **Security → Access and data control → API controls → Manage Domain Wide Delegation → Add new**. + +Google Workspace Admin Console Add a new client ID dialog with Client ID and OAuth scopes fields + +Paste that Client ID into **Client ID**, then enter these exact scopes as a comma-separated list under **OAuth scopes**: + +```text +https://www.googleapis.com/auth/drive.readonly,https://www.googleapis.com/auth/admin.directory.group.readonly,https://www.googleapis.com/auth/admin.directory.domain.readonly +``` + +Select **Authorize**, then **View details** to confirm all three scopes were saved. If your organization requires multi-party approval, another super administrator must approve the request. Delegation changes can take up to 24 hours to propagate. See Google's [Admin Console delegation guide](https://knowledge.workspace.google.com/admin/apps/control-api-access-with-domain-wide-delegation). + +These are Search's central crawl scopes. The general [Google service account guide](/integrations/google-service-account) includes broader scopes for workflow actions; do not copy those into this Search setup. + + + + +### Add the credential in Sim + +In Google Drive's Search setup, choose **Service account**. Open the **Service account** picker, choose its connection action, and paste the JSON key into **Add Google Service Account**. Give it a name and add it. Sim returns you to the source form with that credential selected. + +Add Google Service Account credential modal in Sim + + + + +### Choose the indexing identity + +Set **Crawl as** to a Google Workspace administrator who can read groups, memberships, and domains, and can access the content you want indexed. Select folders if needed, then choose **Connect & Sync**. Sim validates Drive and Directory access before accepting the source. + + + + +## Source options + +| Option | Behavior | +| --- | --- | +| Folders / Folder IDs | Optional. Includes files in each selected folder and its accessible subfolders. A folder selection does not grant access. | +| File Type | All supported files by default, or only Google Docs, Sheets, Slides, or text formats. **Plain text files only** also includes CSV, HTML, Markdown, JSON, and XML. | +| Crawl as | Required for the central service account. In Member accounts, it optionally supplies the impersonated user when a dedicated service account fetches content. It has no effect on ordinary OAuth accounts. | +| Openly shared files | Applies only to central crawls; it has no effect in Member accounts. **Keep out of search** by default. You can include discoverable domain shares or discoverable public shares. Link-only sharing does not grant Search access; named user and group permissions still apply. | +| Document details | Optional owner, file type, modification date, and starred metadata. | + +Sim exports Docs and Slides as text and Sheets as XLSX spreadsheets. Supported uploaded files use the knowledge-base document pipeline, including PDF and Office formats. Unsupported files and oversized exports cannot be indexed; Google limits Workspace exports to 10 MB. See [Drive export formats](https://developers.google.com/workspace/drive/api/guides/ref-export-formats) and [download limits](https://developers.google.com/workspace/drive/api/guides/manage-downloads). + +Search schedules syncs hourly. Content, deletions, and permissions refresh in the background; results are not a live read from Drive. Admins can inspect progress and errors through **Manage** on the source. + +## Troubleshooting + +| Problem | Next step | +| --- | --- | +| Directory access failed | Check the delegated scopes and the **Crawl as** user's administrator privileges. A normal Google OAuth credential cannot supply this central Search path. | +| An existing central source uses a normal Google OAuth account | Replace it with a delegated service account. If the source is **Disabled**, choose **Resume** first. Then open **Manage**, select or add the delegated service account, and choose **Change indexing account**. A **Paused** source can change credentials before you resume it. | +| Missing files in a central crawl | Open them as the **Crawl as** user. Delegation does not grant that user access to all domain files. Check folder and file-type filters. | +| A teammate sees no results | Confirm their verified Sim email matches the Drive permission or group membership. For member accounts, finish their personal Drive connection too. | +| A public or shared-link file is missing | Check **Openly shared files**. Link-only sharing does not grant Search access. A named user or group permission can still make the file searchable. | +| Reconnect or credential error | Reauthorize the member account, or replace the service-account credential and verify delegation, as applicable. | + +## Self-hosted OAuth configuration + +The deployment operator configures Google OAuth for **Member accounts** and **Browse with**. This is separate from the central service account above. + +1. In [Google Cloud Console](https://console.cloud.google.com/), select your project and enable **Google Drive API** under **APIs & Services → Library**. +2. Open **Google Auth platform → Branding** and configure the app name and contact details. Under **Audience**, choose **Internal** for your Google Workspace organization only, or **External** for other users, adding test users while testing. Review **Data Access → Add or remove scopes** using the current Sim scopes below. See Google's [consent guidance](https://developers.google.com/workspace/guides/configure-oauth-consent). +3. Open **Google Auth platform → Clients → Create client**, choose **Web application**, and add this URI under **Authorized redirect URIs**. Add it to the existing Google client if your instance already uses one. + +```text +https:///api/auth/oauth2/callback/google-drive +``` + +Google Cloud Web application client form with Sim's Gmail, Calendar, and Drive callback URLs + +This Google Cloud example uses one client for all three services. Replace `https://sim.example.com` with your Sim origin and add only the callbacks for services you enable. + +Save the client ID and secret as `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`. Set `NEXT_PUBLIC_APP_URL` to the same Sim origin used in the callback, then restart Sim. See [Integrations & OAuth](/platform/self-hosting/integrations-oauth). + +The current Sim Drive OAuth connection uses these scopes: + +```text +openid +https://www.googleapis.com/auth/userinfo.email +https://www.googleapis.com/auth/userinfo.profile +https://www.googleapis.com/auth/drive +https://www.googleapis.com/auth/drive.file +``` + + + Google's `drive.readonly` scope covers Search's file reads. Sim's existing OAuth connection also supports workflow actions and requires the broader scopes above; do not substitute read-only scopes for member OAuth. The central service account uses the separate read-only Drive and Directory scopes listed earlier. See [Google's Drive scope descriptions](https://developers.google.com/workspace/drive/api/guides/api-specific-auth). + diff --git a/apps/docs/content/docs/search/index.mdx b/apps/docs/content/docs/search/index.mdx new file mode 100644 index 00000000000..a03d0366433 --- /dev/null +++ b/apps/docs/content/docs/search/index.mdx @@ -0,0 +1,100 @@ +--- +title: Search +description: Connect your team's sources and search the documents each person can access +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Step, Steps } from 'fumadocs-ui/components/steps' +import { Image } from '@/components/ui/image' + +Search brings your connected sources into one place. An organization admin adds a source and chooses what to include. Teammates then [connect their accounts](/search/connect-your-account) when the source requires it. Each person searches with their own access. + +## Add your first source + + + + +### Choose a source + +As an organization admin, open **Settings → Integrations → Providers**. Approve the provider for Sim Search, then select **Set up** beside it. Use its **Setup guide** for the provider's prerequisites. + +Approval lets members connect; it does not connect an account or start indexing. Slack also requires an admin to configure the organization's Slack app first. + + + + +### Configure it once + +Choose the folders, repositories, calendars, spaces, or channels to include. Start with the defaults unless you need to narrow the scope. **Document details** contains optional metadata. + +Select **Connect & Sync** for an administrator connection, or **Add source** for member accounts. Creating a source does not invite people or authorize their accounts. + + + + +### Connect and search + +In **Integrations**, select **Connect account** if prompted—even if you created the source. Complete authorization in the new tab, then return to the source list. Open **Search** in the organization sidebar to find documents, or **Home** to ask the assistant about them. Documents become available as background indexing progresses. + + + + +Sim Search source catalog with Set up actions + +Source availability depends on the deployment and organization policy. An unavailable source needs operator configuration before setup can continue. + +## Choose the right connection method + +Most sources use member accounts. Google Drive and Confluence also support a central administrator connection; GitLab requires an administrator token for a self-managed instance. + +| Method | What the admin does | What teammates do | +| --- | --- | --- | +| **Member accounts** | Sets the source's filters once. | Connect their own accounts. Sim lists documents using each member's access. | +| **Service account** (Drive) / **Admin or service account** (Confluence) | Connects an account that can read the content and the source's permissions or directory. | Join the organization with a matching verified identity. Confluence also requires each person to connect their account. | +| **Administrator token** (GitLab) | Connects a self-managed instance administrator token and selects projects to index. | Join the organization with a verified Sim email matching GitLab. No personal connection is needed. | + +Some member sources offer **Sync documents with**. **Connected members** uses members' accounts for both content and access checks. Selecting a dedicated account uses it to fetch content; members still connect to establish which documents they may search. **Browse with** only helps an admin pick source options—it does not enroll that account for Search. + + + An administrator connection does not grant everyone access to everything. Search applies the source's supported permission rules. It also does not automatically discover every employee's data: the indexing account must be able to read the configured content. + + +## Connector guides + +| Source | Content | Connection in Search | +| --- | --- | --- | +| [Confluence](/search/confluence) | Pages and blog posts | Admin/service account or member accounts; each teammate connects | +| [GitHub](/search/github) | Repository text files | GitHub App installation plus each member's authorization | +| [GitLab](/search/gitlab) | Repository files, wikis, issues, merge requests | Self-managed instance administrator token; no member connection | +| [Gmail](/search/gmail) | Email thread text | Each member's Gmail account | +| [Google Calendar](/search/google-calendar) | Calendar events | Each member's Google Calendar account | +| [Google Drive](/search/google-drive) | Supported Drive files | Delegated service account or member accounts | +| [Jira](/search/jira) | Issues | Each member's Jira account | +| [Slack](/search/slack) | Channel messages and threads | Slack app installation plus each member's authorization | + +## Bring your team + +Invite people through the organization's **Settings → Members**, or use your organization's [SSO provisioning](/platform/enterprise/sso). Share the organization's **Home** or **Integrations** URL. People need their own Sim account and organization membership; they do not need access to a workspace. Connecting an external account alone does not grant organization membership. + +Organization admins manage source configuration and sync status through **Manage** under **Settings → Integrations → Providers**. Other members connect or reconnect their own accounts. See [Connect your account](/search/connect-your-account) for the teammate walkthrough. + +## Search, Assistant, and MCP + +**Search** in the organization sidebar finds documents directly. The assistant on **Home** can search and read the same sources to answer questions with citations. Conversations are private to their author, including when another organization member is an admin. + +To search from an MCP-compatible app, open **Settings → Search MCP**. Generate a personal Sim API key there, or use an existing personal key with the displayed connection details. MCP applies your current organization membership and document access. + +## Existing workspace Search + +Workspace Search remains separate. Workspace admins add sources through **Search → Add source**; the member-account action is **Create & Invite**. Teammates need workspace access and connect from its source list. Organization Search does not automatically include workspace sources or grant access to workspace content. + +## Check that it works + +1. Let the first sync finish, then search for a distinctive phrase in a document you can open in the source. +2. Open the result's source link and confirm the document is the expected one. +3. Ask a teammate with different source access to repeat the search. Documents restricted to you should not appear for them. +4. Change or remove a test document's access in the source and check again after the next completed content and permission refresh. + +Search runs background syncs on an hourly schedule. Large sources, provider limits, and indexing queues can delay completion. Results are indexed copies, so edits and access changes are not fetched live for every query. Admins can inspect errors and progress under **Manage**. + +These guides cover permission-aware Search sources. For a general knowledge base used by workflows, see [Knowledge-base connectors](/knowledgebase/connectors); its workspace access settings are a separate choice. diff --git a/apps/docs/content/docs/search/jira.mdx b/apps/docs/content/docs/search/jira.mdx new file mode 100644 index 00000000000..0c16e6ecabe --- /dev/null +++ b/apps/docs/content/docs/search/jira.mdx @@ -0,0 +1,141 @@ +--- +title: Jira +description: Connect Jira Cloud projects to Search using each teammate's account +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Step, Steps } from 'fumadocs-ui/components/steps' +import { Image } from '@/components/ui/image' + +Search issue titles, descriptions, and metadata from selected Jira Cloud projects. An organization admin sets up the source; each teammate connects their own Jira account to search the issues they can access. + +This Search connector uses **Member accounts**. It does not offer a central admin crawl. Comments, attachment contents, dashboards, and saved filters are not indexed. + +Admin setup uses your organization's **Settings → Integrations** page. Teammates connect from **Integrations** in the main sidebar. For workspace Search, use **Search → Add source** instead; **Create & Invite** is the workspace equivalent of **Add source**. + +## Before you start + +- A **Sim organization admin** must approve Jira. An admin can configure the source beforehand, or the first member connection can supply the required site and project settings. +- Use an Atlassian Cloud site such as `your-team.atlassian.net`. Jira Server and Data Center are not supported by this connector. +- Each person needs a verified Sim email matching the email on their active Atlassian account, plus access to the selected Jira site and projects. Jira's **Browse Projects** and issue security permissions still determine which issues they can search. + +On hosted Sim, teammates authorize the existing Sim app. They do not create an Atlassian app or API token. Deployment owners running their own Sim instance configure the [shared OAuth app](#self-hosted-operator-setup) once. + + +Sim uses its existing Jira OAuth integration. Search uses `read:jira-work` to read issues, `read:me` to identify the connected person, and `offline_access` to refresh the connection. The authorization screen also includes permissions for other Jira features, including writes. Review the requested permissions before authorizing. + + +## Set up the source + + + + +### Choose Jira + +Open **Settings → Integrations → Providers**, approve **Jira**, then select **Set up**. The connection method is **Member accounts**. + + + + +### Choose the projects + +Under **Browse with**, select an account or choose **Connect Jira account** and complete Atlassian authorization. Enter **Jira Domain**, then choose one or more **Projects**. + +If you already know the project keys, use the switch beside **Projects** to select manual input and enter keys such as `ENG, SUPPORT`. Manual input lets you configure the source without connecting a browsing account first. + +**Browse with** only populates the project picker. It does not enroll you or share that account's issue access with teammates. + +Jira Search source setup with member accounts, an example site, and a project key + + + + +### Create the source + +Leave **JQL Filter** empty to include all accessible issues in the selected projects, or add a condition such as `status = "Done"`. Open **Document details (optional)** only if you want to change metadata tags. + +Click **Add source**. The source appears in the shared source list, and Sim starts preparing member connections in the background. + + + + +### Connect your search account + +On the new Jira row, click **Connect account**. Complete the connection in the new tab using the Atlassian email that matches your verified Sim email. Select the configured Atlassian site when asked and grant the requested permissions. + +Return to Integrations to see connection and indexing status. Each teammate follows this same step. A previously authorized account may already be connected. + + + + +## Configuration + +| Setting | What to enter | +| --- | --- | +| **Jira Domain** | The Cloud site hostname, such as `your-team.atlassian.net`. Use the same site during authorization. | +| **Projects / Project Keys** | One or more projects. The picker shows projects available to the browsing account; manual input accepts comma-separated keys. | +| **JQL Filter** | Optional conditions that narrow the selected projects. Leave out `ORDER BY`; Sim supplies the sorting. | +| **Document details** | Optional issue type, status, priority, labels, assignee, and last-updated tags. | + +Search manages the sync schedule. Item limits and sync frequency are not setup decisions on this page. + +## Teammates and ongoing sync + +Existing organization members see the same source configuration and their own **Connect account**, **Reconnect**, or indexing status. They do not choose projects again. Invite new teammates to the Sim organization through its Members settings or SSO onboarding, then have them open Integrations and connect Jira. A Jira authorization does not grant Sim organization membership. + +Sim checks Jira separately using each connected person's account. Issue content and tags become searchable as processing finishes; changes and lost issue access are picked up by later syncs. The source row reports the number of documents searchable by the current viewer. Admins can open **Manage** to review sync status or update the source. + +## Troubleshooting + +| What you see | What to do | +| --- | --- | +| No provider setup controls | Ask a Sim organization admin to approve and set up Jira. | +| Projects are empty or disabled | Enter the domain and connect a browsing account, or switch to manual project keys. Check that the account can browse those projects. | +| Connected, but no issues | Confirm the authorized site matches the configured domain. Check project access, issue security, and the JQL filter. An admin's Jira access does not grant access to other members. | +| Email mismatch | Sign in to Atlassian with the email shown by Sim's connection flow. | +| Atlassian says the callback URL is invalid | Ask the deployment operator to check the OAuth app identified by `JIRA_CLIENT_ID`. Its saved callback must exactly match the authorization request's `redirect_uri`, including scheme, hostname, port, and `/api/auth/oauth2/callback/jira` path. | +| **Reconnect** | Reauthorize the Jira account and grant all requested permissions. This is needed after a grant is revoked or its required permissions change. | +| Connection tab does not open | Allow pop-ups for Sim, then click **Connect account** again. | + +### Check access in Jira + +First, open a missing issue in Jira using the same account you connected to Sim. If you cannot open it there, ask a Jira admin to check its project permissions and issue security. + +For company-managed projects, an admin can open **Settings → System → Admin Helper → Permission Helper**, enter the affected user and issue key, and check **Browse Projects**. The result explains which permission condition failed. Fix access in Jira, then let the next Sim sync finish. See Atlassian's [Permission Helper instructions](https://support.atlassian.com/jira-cloud-administration/docs/check-a-users-access-from-a-work-item/) and [illustrated permissions tutorial](https://www.atlassian.com/software/jira/guides/permissions/tutorials). + +Atlassian illustration of Jira's Permission helper with User and Issue fields and Browse Projects selected + +Atlassian illustration from its [permissions tutorial](https://www.atlassian.com/software/jira/guides/permissions/tutorials). UI labels may vary by Jira version. + +## Self-hosted operator setup + +The deployment operator configures one shared Jira OAuth integration. Teammates continue to start **Connect account** from Sim. + +1. Open the [Atlassian developer console](https://developer.atlassian.com/console/myapps/) and select your deployment's **OAuth 2.0 integration**, or create one for the deployment. +2. Under **Authorization**, configure **OAuth 2.0 (3LO)**. Add `https:///api/auth/oauth2/callback/jira` to **Callback URLs**, keeping any callbacks already used by your deployment, then save. + + Atlassian OAuth Authorization form with an example Sim Jira callback URL + + Example callback in Atlassian's developer console. Replace `sim.example.com` with your Sim domain. + +3. Under **Permissions**, add **Jira API**, then **Configure** its classic and granular scopes for Jira, Jira Service Management, and Assets. Separately add **User Identity API** with `read:me`. Sim requests `offline_access` in the authorization URL for refresh tokens. Configure the full `jira` scope list for your release in [Sim's OAuth configuration](https://github.com/simstudioai/sim/blob/staging/apps/sim/lib/oauth/oauth.ts); the Search read scopes above are only a subset of this shared integration's permissions. +4. Under **Distribution**, enable sharing so teammates can authorize the app. Copy the client ID and secret from **Settings** into `JIRA_CLIENT_ID` and `JIRA_CLIENT_SECRET`, set the correct `NEXT_PUBLIC_APP_URL`, and restart Sim. +5. Start a connection from Search. Confirm that Atlassian lists the intended site, then return to Sim. After changing requested scopes, reconnect previously authorized accounts. + +For a local instance using `NEXT_PUBLIC_APP_URL=http://localhost:3000`, register `http://localhost:3000/api/auth/oauth2/callback/jira`. Use a separate development OAuth app when production callbacks must remain unchanged. After updating local client credentials or the app URL, restart Sim and begin a new connection from Search. If only the app owner can connect, check **Distribution**. See Atlassian's [OAuth configuration and sharing guide](https://developer.atlassian.com/cloud/jira/platform/oauth-2-3lo-apps/) and Sim's [deployment reference](/platform/self-hosting/integrations-oauth). diff --git a/apps/docs/content/docs/search/meta.json b/apps/docs/content/docs/search/meta.json new file mode 100644 index 00000000000..8572aa0c75d --- /dev/null +++ b/apps/docs/content/docs/search/meta.json @@ -0,0 +1,15 @@ +{ + "title": "Search", + "pages": [ + "index", + "connect-your-account", + "confluence", + "github", + "gitlab", + "gmail", + "google-calendar", + "google-drive", + "jira", + "slack" + ] +} diff --git a/apps/docs/content/docs/search/slack.mdx b/apps/docs/content/docs/search/slack.mdx new file mode 100644 index 00000000000..61ce37a9c81 --- /dev/null +++ b/apps/docs/content/docs/search/slack.mdx @@ -0,0 +1,116 @@ +--- +title: Slack +description: Set up a workspace Slack app and connect members for channel search +--- + +import { Step, Steps } from 'fumadocs-ui/components/steps' +import { Image } from '@/components/ui/image' + +Slack Search indexes channel messages and threads. A Sim organization admin configures your Slack app once, then each teammate authorizes their own Slack account. Their results are limited to the selected public channels and private channels they can access. DMs and group DMs are not indexed. + +Slack source setup in Sim Search + +## Before you start + +You need a Sim organization admin and permission to create and install an app in the target Slack workspace. Ask a Slack workspace admin for approval when app installation is restricted. Use the same email address for Slack and your verified Sim account. + +This guide covers **indexing Slack messages for Search and MCP**. It does not install a Sim assistant that answers inside Slack. + +## Set up the organization's Slack app + +Skip to **Connect the source** if the organization already has a verified Slack app for member accounts. + + + + +### Open provider settings + +Open **Settings → Integrations → Providers**. Approve **Slack**, select **Set up**, then **Set up Slack**. Approval alone does not configure the app or start indexing. + + + + +### Configure an app in Slack + +On the [Slack Apps page](https://api.slack.com/apps), create an app for the target workspace, or use an app dedicated to your Sim organization. Under **OAuth & Permissions**, add the user scopes and both redirect URLs listed below. Keep **Token Rotation** disabled. + +Slack app settings showing Basic Information and App Credentials + +*Official Slack example: [Basic Information](https://docs.slack.dev/tools/bolt-python/creating-an-app/#create-a-new-app). Use your own app's credentials.* + +In Sim, enter these four fields: + +| Sim field | Where to find it | +|---|---| +| Slack App ID | Slack app **Basic Information → App Credentials** (`A…`). | +| Slack workspace ID | The workspace segment of the Slack web URL, `app.slack.com/client/T…/…`. | +| Client ID | The same app's **Basic Information → App Credentials**. | +| Client Secret | The same app's **Basic Information → App Credentials**. | + +Organization setup uses personal user authorization. It does not ask for a bot token or signing secret. + + + + +### Verify and continue + +Select **Verify and add**, then authorize the app in the Slack popup. Sim verifies the app, workspace, client credentials, and required scopes. Allow popups if the window does not open. + +After verification, Sim returns to the source form. If Slack requires administrator approval, complete that approval before continuing. + + + + +## Connect the source + +Keep **Sync documents with → Connected members** for the usual setup. Configure only the limits you need: + +| Field | Behavior | +|---|---| +| Channels | Leave blank for all accessible public and private channels, or choose channel names/IDs. | +| Excluded Channels | Names or IDs to omit; exclusions override included channels. | +| Archived Channels | Included by default. | +| Earliest Message Date | Optional UTC date (`YYYY-MM-DD`). Applies to the thread's first message; replies are included with that thread. | + +Select **Add source**. On the source row, each person selects **Connect account** and approves the configured Slack app. Creating the source or verifying the Slack app does not authorize teammates automatically. + +You can instead select an existing account under **Sync documents with** to supply message content centrally. Members still connect their own accounts to establish access. The selected account must itself be able to read the selected channels. + +Admins can use **Manage** to inspect sync progress. Search reads the indexed content, so source changes appear after background syncing. Slack retention and API limits determine how much history is available. + +## Permissions reference + +The organization account pool supports Search and workspace workflow tools. Its current authorization requests the following **User Token Scopes**, including write permissions. Search itself only indexes channel messages and threads; workspace use is controlled separately in the organization account settings. + +| Purpose | User scopes | +|---|---| +| Public channels | `channels:read`, `channels:history`, `channels:write` | +| Private channels | `groups:read`, `groups:history`, `groups:write` | +| Messages and conversations | `chat:write`, `im:read`, `im:history`, `im:write`, `mpim:read`, `mpim:history`, `mpim:write` | +| Files and canvases | `files:read`, `files:write`, `canvases:read`, `canvases:write` | +| Reactions | `reactions:read`, `reactions:write` | +| Identity and profile | `users:read`, `users:read.email`, `users.profile:read`, `users.profile:write` | + +Add both redirect URLs under **OAuth & Permissions → Redirect URLs**, using your Sim origin: + +```text +https:///api/credential-groups/slack-managed-users/callback +https:///api/credential-groups/oauth/slack/callback +``` + +Compare **OAuth & Permissions → Scopes → User Token Scopes** with the table above. If scopes change, update the Slack app and have members reconnect. Do not change a shared production app's credentials to configure a separate test installation. + +For existing **workspace** Search, use **Search → Add source → Slack**. That flow uses the custom-bot wizard and **Connected accounts → Access → Search documents**, which requests the six read-only channel and identity scopes instead. Its bot installation is separate from the organization setup described here. + +See Slack's [app manifest reference](https://docs.slack.dev/reference/app-manifest/) and [user token access model](https://docs.slack.dev/authentication/tokens/). + +## Troubleshooting + +| Problem | Next step | +|---|---| +| Setup keeps asking for a Slack app | Finish **Verify and add** in the Slack setup; approval alone is insufficient. | +| Redirect mismatch | Check both redirect URLs above against your Sim origin. | +| App or workspace mismatch | Use the App ID and client credentials from the same app, and the ID of the workspace being authorized. | +| Missing scopes | Compare User Token Scopes with the table above, update the Slack app, reinstall as Slack requires, and reconnect. In workspace setup, select **Search documents** in both setup screens. | +| Missing private-channel results | Confirm the member is in the channel and it is within the source filters. With an indexing account, confirm that account can read it too. | +| Slow initial indexing | Check sync status and Slack rate limits. A large history can take multiple background runs. | diff --git a/apps/docs/content/docs/workflows/blocks/credential.mdx b/apps/docs/content/docs/workflows/blocks/credential.mdx index ae28df764a2..a6044fea0a0 100644 --- a/apps/docs/content/docs/workflows/blocks/credential.mdx +++ b/apps/docs/content/docs/workflows/blocks/credential.mdx @@ -1,6 +1,6 @@ --- title: Credential -description: The Credential block outputs an OAuth credential's ID reference for downstream blocks to use. +description: Select workspace OAuth credentials or find organization OAuth and MCP account references for downstream blocks. --- import { Callout } from 'fumadocs-ui/components/callout' @@ -14,7 +14,7 @@ import { } from '@/components/workflow-preview' import { FAQ } from '@/components/ui/faq' -The **Credential block** hands a downstream block an OAuth credential to use, without exposing the secret. It has two operations: **Select Credential** outputs one credential's ID reference for other blocks to use; **List Credentials** returns all the workspace's OAuth credentials (optionally filtered by provider) as an array to iterate over. +The **Credential block** passes account references to downstream blocks without exposing tokens. **Select Credential** and **List Credentials** use workspace OAuth credentials. When [organization connected accounts](/platform/connected-accounts) is enabled and shared with the workflow's workspace, the organization operations find or list contributed OAuth accounts and managed MCP connections. @@ -30,6 +30,10 @@ The **Credential block** hands a downstream block an OAuth credential to use, wi |---|---| | **Select Credential** | Pick one OAuth credential and output its reference — use this to wire a single credential into downstream blocks | | **List Credentials** | Return all OAuth credentials in the workspace as an array — use this with a ForEach loop | +| **Find Organization Account** | Find exactly one active OAuth contribution by invitation email and provider | +| **List Organization Accounts** | Return a page of active OAuth contributions, optionally filtered by email and providers | +| **Find Organization MCP Connection** | Find exactly one active managed MCP connection by invitation email and MCP provider | +| **List Organization MCP Connections** | Return a page of active managed MCP connections, optionally filtered by email and provider | ### Credential (Select operation) @@ -73,6 +77,68 @@ Filter the returned OAuth credentials by provider. Select one or more providers
+## Organization accounts + +An organization owner or admin must first [set up connected accounts](/platform/connected-accounts) and allow this workflow's workspace. The block uses the organization that owns the workspace; there is no credential group or organization selector. + +Every authorized workflow in an allowed workspace can use every active contribution in the organization's pool. Results are not restricted to the running user's own accounts, and no separate per-workflow grant is required. Normal workflow permissions still apply. + +### Inputs + +| Operation | Required fields | Optional fields | +| --- | --- | --- | +| **Find Organization Account** | Email, Provider | — | +| **List Organization Accounts** | — | Email, Providers, Limit, Cursor | +| **Find Organization MCP Connection** | Email, MCP provider | — | +| **List Organization MCP Connections** | — | Email, MCP provider, Limit, Cursor | + +For list operations, **Limit** accepts 1–100 and defaults to 100. **Cursor** accepts the previous page's `nextCursor`. + +**Email** refers to the address used for the person's invitation. Sim associates that invitation with their verified Sim user. See [email association](/platform/connected-accounts#how-the-email-is-associated-with-a-sim-user) for how OAuth and managed MCP identity checks differ. + +Find operations fail unless there is exactly one active matching connection. List operations return an empty array when there are no matches and omit inactive or revoked connections. + +### OAuth outputs + +**Find Organization Account** returns `credentialId`, `displayName`, `providerId`, and the invitation `email`. Pass `credentialId` into the corresponding integration block's credential field in advanced mode. + +**List Organization Accounts** returns these account references in `credentials`, along with `count`, `hasMore`, and `nextCursor`. `count` is the number returned on this page. Feed `credentials` into a ForEach loop and use `` inside the loop. To process additional pages, pass `nextCursor` into another call with the same filters while `hasMore` is true; the block does not fetch all pages automatically. + +For example, name a Credential block **account**, choose **Find Organization Account**, set **Email** to `alex@example.com`, and select **Gmail**. Reference `` in a Gmail block to act using Alex's contribution. + +### Managed MCP outputs + +**Find Organization MCP Connection** returns: + +| Output | Type | Description | +| --- | --- | --- | +| `credentialId` | `string` | The person's managed MCP connection ID; use this connection for MCP calls | +| `email` | `string` | Invitation email used to find the connection | +| `displayName` | `string` | Connection display name | +| `mcpServerId` | `string` | Shared MCP configuration ID | +| `mcpServerName` | `string` | Configured MCP server name | +| `toolNames` | `json` | Tool names available to this connection | + +**List Organization MCP Connections** returns these objects in `mcpConnections`, plus `count`, `hasMore`, and `nextCursor`. Pagination works the same way as for organization OAuth accounts; `nextCursor` is `null` on the last page. + + +For a managed MCP account, use the returned **`credentialId`** to select the person's connection in the MCP Tool block. **`mcpServerId`** identifies the shared provider configuration; it does not identify a person's authorization. No OAuth token or client secret is returned by the Credential block. + + +## Connection-event triggers + +Switch the Credential block to trigger mode to start a workflow when an account connects or a connection form is submitted. Select an **Event** and deploy the workflow in an allowed workspace. + +| Event | When it runs | +| --- | --- | +| **Credential Added** | A person adds a new account contribution | +| **Credential Reconnected** | A person reconnects an existing contribution | +| **Account Connections Submitted** | A person submits the connection form | + +Events include `event`, `timestamp`, `email`, `enrollmentId`, `enrollmentStatus`, `credentialGroupId`, and `credentialGroupName`. Added and reconnected events also include account details such as `credentialId`, `provider`, and `displayName`; `mcpServerId` identifies shared configuration for an MCP connection and is `null` for an OAuth account. + +Each deployed workflow that selects the event in an allowed workspace can receive it. Removing workspace access stops subsequent event delivery. Legacy **Credential Group** blocks must be replaced with the Credential block; they are not automatically converted. + ## Examples ### Share one credential across blocks @@ -123,17 +189,19 @@ The same reference works for any OAuth block. In a Gmail or Slack block's creden ## Best Practices - **Define once, reference many times**: When five blocks use the same Google account, use one Credential block and wire all five to `` instead of selecting the account five times -- **Outputs are safe to log**: The `credentialId` output is a UUID reference, not a secret. It is safe to inspect in execution logs +- **Inspect references, not tokens**: The block returns credential IDs and account metadata rather than tokens. Organization outputs can include people's email addresses - **Use for environment switching**: Pair with a Condition block to route to a production or staging OAuth credential based on a workflow variable - **Advanced mode is required**: Downstream blocks must be in advanced mode on their credential field to accept a dynamic reference - **Use List + ForEach for fan-out**: When you need to run the same action across all accounts of a provider, List Credentials feeds naturally into a ForEach loop - **Narrow by provider**: Use the Provider multiselect to filter to specific services — only providers you have credentials for are shown in your Function block's code. Note that the function will receive the raw UUID string — if you need the resolved token, the downstream block must handle the resolution (as integration blocks do). The Function block does not automatically resolve credential IDs." }, + { question: "Can I use a Credential block output in a Function block?", answer: "Yes. Reference in your Function block's code. The function receives the ID string. It does not automatically resolve credential IDs to tokens; use a compatible integration or MCP block to perform an authenticated action." }, { question: "What happens if the credential is deleted?", answer: "The Select operation will throw an error at execution time: 'Credential not found'. The List operation will simply omit the deleted credential from the results. Update the Credential block to select a valid credential before re-running." }, ]} /> diff --git a/apps/docs/openapi-v2-billing.json b/apps/docs/openapi-v2-billing.json index 024a60372e0..97d0ce13726 100644 --- a/apps/docs/openapi-v2-billing.json +++ b/apps/docs/openapi-v2-billing.json @@ -29,6 +29,9 @@ "security": [ { "apiKey": [] + }, + { + "oauthBearer": [] } ], "paths": { @@ -36,7 +39,9 @@ "get": { "operationId": "getBillingStatus", "summary": "Get Billing Status", - "description": "Return the current plan, billing standing, credit allowance, and storage quota. `credits` and `storage` report the payer's pooled allowances and are null unless the caller can manage that payer's billing; they are always null for a workspace API key. Billing history lives at `GET /api/v2/billing/logs`.", + "description": "Get the current plan, billing standing, credit allowance, and storage quota. Pooled `credits` and `storage` are visible only to callers who can manage the payer's billing; workspace API keys receive null for both. Use List Billing Logs for credit history.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "billing.status.read", + "x-oauth-scope": "api:read", "tags": ["Billing"], "parameters": [ { @@ -102,7 +107,9 @@ "get": { "operationId": "listBillingLogs", "summary": "List Billing Logs", - "description": "List the credit-denominated billing ledger with source filtering and opaque cursor pagination. `period` defaults to `30d`, so an unqualified request covers only the last 30 days: paginating to `nextCursor: null` exhausts that window, not the whole ledger. An inverted custom window is a 400 rather than an empty page.", + "description": "List credit usage with source filtering and cursor pagination. The default `period` is `30d`; pagination covers only the selected time window. An inverted custom window returns `400`.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "billing.logs.list", + "x-oauth-scope": "api:read", "tags": ["Billing"], "parameters": [ { @@ -131,9 +138,9 @@ "name": "workspaceId", "in": "query", "required": false, - "description": "Narrow the ledger to usage events attributed to one workspace. It does not change whose events are reported — a personal API key always reports the usage of the person holding it, and a workspace API key always reports its own workspace's complete ledger across every member. The response `scope` field says which of the two you received. A workspace API key is pinned to its own workspace: any other id answers `404 Workspace not found`, which is also what an id that does not exist answers.", + "description": "Narrow the ledger to one workspace. An OAuth token or personal API key reports only its user's events; a workspace API key reports every member's events in its bound workspace. The response `scope` identifies which view was returned. A workspace key asking for another workspace receives the same `404 Workspace not found` as an unknown id.", "schema": { - "description": "Narrow the ledger to usage events attributed to one workspace. It does not change whose events are reported — a personal API key always reports the usage of the person holding it, and a workspace API key always reports its own workspace's complete ledger across every member. The response `scope` field says which of the two you received. A workspace API key is pinned to its own workspace: any other id answers `404 Workspace not found`, which is also what an id that does not exist answers.", + "description": "Narrow the ledger to one workspace. An OAuth token or personal API key reports only its user's events; a workspace API key reports every member's events in its bound workspace. The response `scope` identifies which view was returned. A workspace key asking for another workspace receives the same `404 Workspace not found` as an unknown id.", "type": "string", "minLength": 1, "maxLength": 128 @@ -254,6 +261,12 @@ "in": "header", "name": "X-API-Key", "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description." + }, + "oauthBearer": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "OAuth 2.0 access token", + "description": "A Sim OAuth access token obtained by a registered client through the authorization-code flow. Each operation declares its required scope: api:read permits reads and searches; api:write also permits changes and execution and implies api:read. Scope requirements follow the application operation, independent of HTTP method or workspace role." } }, "headers": { @@ -325,7 +338,7 @@ } }, "Unauthorized": { - "description": "The API key is missing or invalid.", + "description": "The API credential is missing or invalid.", "content": { "application/json": { "schema": { @@ -334,7 +347,7 @@ "example": { "error": { "code": "UNAUTHORIZED", - "message": "API key required" + "message": "Authentication required" } } } @@ -438,6 +451,49 @@ } }, "schemas": { + "V2ActionableForbiddenDetails": { + "type": "object", + "properties": { + "code": { + "$ref": "#/components/schemas/V2ForbiddenDetailCode" + } + }, + "required": ["code"], + "additionalProperties": { + "description": "Additional context for this refusal." + }, + "title": "Actionable forbidden details", + "description": "Machine-readable cause and optional context for an actionable `403` response." + }, + "V2ForbiddenDetailCode": { + "type": "string", + "enum": [ + "INSUFFICIENT_WORKSPACE_ROLE", + "PERSONAL_API_KEYS_DISABLED", + "WORKSPACE_KEY_OPERATION_NOT_PERMITTED", + "PRINCIPAL_KIND_NOT_PERMITTED", + "ORGANIZATION_MEMBERSHIP_REQUIRED", + "ORGANIZATION_ADMIN_REQUIRED", + "ENTERPRISE_PLAN_REQUIRED", + "ORGANIZATION_PLAN_REQUIRED", + "AUDIT_LOGS_DISABLED", + "SKILL_EDITOR_ACCESS_REQUIRED", + "SECRET_ADMIN_ACCESS_REQUIRED", + "WORKSPACE_RESOURCE_LIMIT_REACHED", + "PUBLIC_SHARING_NOT_ALLOWED", + "CREDENTIAL_ADMIN_ACCESS_REQUIRED", + "MCP_SERVER_URL_NOT_ALLOWED", + "WORKSPACE_PLAN_CAPABILITY_REQUIRED", + "CHAT_AUTH_MODE_NOT_PERMITTED", + "CONNECTOR_MANAGED_RESOURCE_READ_ONLY", + "PERMISSION_GROUP_CAPABILITY_BLOCKED", + "INTEGRATION_NOT_ALLOWED", + "INSUFFICIENT_SCOPE", + "SCIM_MANAGED_MEMBERSHIP" + ], + "title": "Forbidden detail code", + "description": "Stable cause code for an actionable `403` response." + }, "V2Error": { "type": "object", "properties": { @@ -453,7 +509,15 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `ORGANIZATION_PLAN_REQUIRED` — The organization has no active organization subscription (Pro for Teams, Max for Teams, or Enterprise).\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\n- `WORKSPACE_PLAN_CAPABILITY_REQUIRED` — The workspace's plan does not include a capability this request depends on. The message names the capability; upgrading the workspace's plan is the remedy.\n- `CHAT_AUTH_MODE_NOT_PERMITTED` — The workspace's permission group does not allow the chat authentication mode the request selected. A mode already saved on the deployment may still be re-saved; changing to a disallowed one cannot.\n- `CONNECTOR_MANAGED_RESOURCE_READ_ONLY` — This resource is managed by a knowledge base connector and cannot be edited directly. Change it at the source and re-sync, or exclude the document from the connector.\n- `PERMISSION_GROUP_CAPABILITY_BLOCKED` — The caller's permission group does not allow this capability. The message names it; an organization admin controls the group.\n- `INTEGRATION_NOT_ALLOWED` — The integration this request names is outside the workspace's allowed set. An organization admin controls the permission group's integration allowlist, and a self-hosted deployment can narrow it further with ALLOWED_INTEGRATIONS." + "description": "Structured error context whose keys depend on the error. Actionable `403` responses use the `V2ActionableForbiddenDetails` shape; validation failures may return issue arrays instead.", + "anyOf": [ + { + "$ref": "#/components/schemas/V2ActionableForbiddenDetails" + }, + { + "description": "Other structured context defined by the specific error." + } + ] } }, "required": ["code", "message"], @@ -728,7 +792,7 @@ "scope": { "type": "string", "enum": ["user", "workspace"], - "description": "Whose usage this page reports. `user` — the events of the person whose personal API key made the request, narrowed by `workspaceId` when one was given; this omits other members' usage. `workspace` — every member's events for the workspace a workspace API key is pinned to." + "description": "Whose usage this page reports. `user` contains only the OAuth or personal-key user's events, optionally narrowed by `workspaceId`; it omits other members. `workspace` contains every member's events for the workspace API key's bound workspace." } }, "required": ["data", "nextCursor", "scope"], diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index 2ae1f1de435..956ffba38ed 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -33,6 +33,9 @@ "security": [ { "apiKey": [] + }, + { + "oauthBearer": [] } ], "paths": { @@ -40,7 +43,9 @@ "get": { "operationId": "listFiles", "summary": "List Files", - "description": "List workspace files with search, sorting, folder filtering, and opaque cursor pagination. Defaults to active files; pass `scope=archived` to page over soft-deleted ones. A workspace folder tree over 10,000 folders is a `413`.", + "description": "List active workspace files with folder filtering, search, sorting, and cursor pagination. Use `scope=archived` to find files available for restoration. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "files.list", + "x-oauth-scope": "api:read", "tags": ["Files"], "parameters": [ { @@ -59,9 +64,9 @@ "name": "folderPath", "in": "query", "required": false, - "description": "Restrict results to files inside this folder — its direct children, or its whole subtree when `recursive` is true. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", + "description": "Restrict files to this folder, including subfolders when `recursive` is true. Unknown folder paths contribute no matches.", "schema": { - "description": "Restrict results to files inside this folder — its direct children, or its whole subtree when `recursive` is true. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", + "description": "Restrict files to this folder, including subfolders when `recursive` is true. Unknown folder paths contribute no matches.", "$ref": "#/components/schemas/FolderPathInput" } }, @@ -69,9 +74,9 @@ "name": "recursive", "in": "query", "required": false, - "description": "Whether the folder filter includes files in subfolders. Defaults to true when a search is set, false otherwise, so listing a folder shows that folder while searching one looks through everything in it. Ignored when no folder filter is set, which already spans the workspace. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", + "description": "Include subfolders in the folder filter. Defaults to true when searching and false otherwise. Ignored without a folder filter.", "schema": { - "description": "Whether the folder filter includes files in subfolders. Defaults to true when a search is set, false otherwise, so listing a folder shows that folder while searching one looks through everything in it. Ignored when no folder filter is set, which already spans the workspace. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", + "description": "Include subfolders in the folder filter. Defaults to true when searching and false otherwise. Ignored without a folder filter.", "enum": [ "true", "1", @@ -211,7 +216,9 @@ "post": { "operationId": "createFile", "summary": "Create File", - "description": "Create a workspace file from inline UTF-8 or base64 content. Use an upload session for streamed or larger files.", + "description": "Create a workspace file from inline UTF-8 or base64 content. Use an upload session for streamed or larger files.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.create", + "x-oauth-scope": "api:write", "tags": ["Files"], "requestBody": { "required": true, @@ -283,7 +290,9 @@ "post": { "operationId": "createFileUpload", "summary": "Create File Upload", - "description": "Create a resumable upload session and receive either a signed PUT URL or multipart instructions.", + "description": "Create a resumable upload session and receive either a signed PUT URL or multipart instructions.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.upload.create", + "x-oauth-scope": "api:write", "tags": ["Files"], "requestBody": { "required": true, @@ -352,7 +361,9 @@ "get": { "operationId": "getFileUpload", "summary": "Get File Upload", - "description": "Read an upload session's current state — whether it is still accepting bytes, has finalized into a file, or has failed. Use it to decide whether an interrupted transfer can be resumed or should be abandoned. Like every other upload control leg it requires the signed upload token, and is re-authorized against the workspace on each call.", + "description": "Get an upload session's state to determine whether an interrupted transfer can resume. Requires the signed upload token and current workspace access.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "files.upload.read", + "x-oauth-scope": "api:read", "tags": ["Files"], "parameters": [ { @@ -438,7 +449,9 @@ "delete": { "operationId": "abortFileUpload", "summary": "Abort File Upload", - "description": "Abort an active upload session and release provider-side multipart state.", + "description": "Abort an incomplete upload session and discard its uploaded data. Completed uploads cannot be aborted.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.upload.cancel", + "x-oauth-scope": "api:write", "tags": ["Files"], "parameters": [ { @@ -529,7 +542,9 @@ "post": { "operationId": "createFileUploadPartUrls", "summary": "Create File Upload Part URLs", - "description": "Create signed URLs for a bounded set of multipart upload part numbers.", + "description": "Create signed URLs for a bounded set of multipart upload part numbers.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.upload.parts", + "x-oauth-scope": "api:write", "tags": ["Files"], "parameters": [ { @@ -637,7 +652,9 @@ "post": { "operationId": "completeFileUpload", "summary": "Complete File Upload", - "description": "Finalize uploaded bytes, verify provider state, and begin atomic workspace-file registration.", + "description": "Finalize an upload and register its workspace file. Repeating a completed upload returns the existing file.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.upload.complete", + "x-oauth-scope": "api:write", "tags": ["Files"], "parameters": [ { @@ -728,7 +745,9 @@ "get": { "operationId": "readFileText", "summary": "Read File Text", - "description": "Return a file's text content, parsed out of the stored bytes. This reads the file; it writes nothing — `POST /api/v2/files/{fileId}/unzip` is the endpoint that unzips an archive into the workspace. Answers `400` for a type no parser supports, naming the raw-bytes download as the escape hatch, and `413` for a file above the extraction ceiling. A generated document is extracted from its compiled artifact rather than its generation source, so one still compiling answers `409` and is worth retrying. **`degraded: true` means text extraction did not fully succeed and the returned text may be incomplete or synthesized from the file's raw bytes. Do not treat it as authoritative content.** The legacy `.doc` and `.ppt` parsers deliberately return best-effort content rather than failing, so this flag — not an error status — is how a partial extraction is reported. `truncated` separately reports that a parser limit stopped extraction early.", + "description": "Extract text without changing the file. Use Unzip File to unpack archives or Download File for original bytes. Unsupported types return `400`, compiling documents return `409`, and oversized files return `413`. `degraded: true` indicates incomplete or synthesized text, including some legacy `.doc` and `.ppt` results; `truncated: true` indicates a parser limit.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "files.read_content", + "x-oauth-scope": "api:read", "tags": ["Files"], "parameters": [ { @@ -849,7 +868,9 @@ "get": { "operationId": "bulkDownloadFiles", "summary": "Bulk Download Files", - "description": "Stream a selection of workspace files as one zip. Select files by id and folders by path, each as one comma-separated parameter; a folder expands to all its descendants, and a path matching no folder is rejected rather than ignored. Each parameter accepts at most 100 entries — the same ceiling the resolved selection is held to — and the resolved file count and total bytes are checked again, so an over-broad selection answers `400` rather than streaming indefinitely. Downloading records an audit event, so it is not a safe read. A `HEAD` skips the effect but is authorized exactly as the `GET` is, so it answers `400`, `401`, `403`, or `404` wherever the `GET` would and an empty `200` otherwise. Skipping the effect means skipping the read that produces the payload, so that `200` carries none of the response headers documented below — it answers whether the `GET` would be allowed, not what the `GET` would return. In particular a `HEAD` does not report `Content-Length`, so it cannot be used to size a download in advance; read the size from the file resource instead.", + "description": "Stream selected files and recursive folder contents as a ZIP archive. Each selection parameter and the resolved set allow 100 entries; unmatched paths or excess entries return `400`. Total bytes are bounded. Downloads record an audit event. `HEAD` checks access with the same authorization as `GET` but skips side effects, returning an empty `200` without payload headers on success. `HEAD` omits `Content-Length`; use file metadata to size downloads.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "files.download", + "x-oauth-scope": "api:read", "tags": ["Files"], "parameters": [ { @@ -878,9 +899,9 @@ "name": "folderPaths", "in": "query", "required": false, - "description": "Folder paths to include with all their descendants, comma-separated. At most 100 entries, and the files they resolve to count against the same 100-file download ceiling. A path that matches no folder is rejected rather than ignored.", + "description": "Comma-separated folder paths whose contents are included recursively. Up to 100 paths; resolved files share the 100-file download limit. Unknown paths are rejected.", "schema": { - "description": "Folder paths to include with all their descendants, comma-separated. At most 100 entries, and the files they resolve to count against the same 100-file download ceiling. A path that matches no folder is rejected rather than ignored.", + "description": "Comma-separated folder paths whose contents are included recursively. Up to 100 paths; resolved files share the 100-file download limit. Unknown paths are rejected.", "type": "string" } } @@ -945,7 +966,9 @@ "post": { "operationId": "unzipFile", "summary": "Unzip File", - "description": "Unzip a `.zip` archive into a new folder beside it and answer counts plus the destination path. This writes new workspace files; it does not read anything out of the archive into the response — `GET /api/v2/files/{fileId}/text` is the endpoint that returns a file's text. The unpacked files are deliberately not returned — a large archive would materialize thousands of objects into one response — so page `GET /api/v2/files?folderPath=...` for the contents. Unzipping is slow: an archive near the size ceiling can run for minutes. Only one unzip of a given archive runs at a time; a concurrent attempt answers `409`. Archives past the size ceiling, and runs that outrun their time budget, answer `413`.", + "description": "Extract a ZIP archive into a new sibling folder and return counts and the destination path. Use List Files to inspect its contents. Large archives can take minutes; concurrent extraction of the same archive returns `409`. Size or processing-time limits return `413`.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.extract_archive", + "x-oauth-scope": "api:write", "tags": ["Files"], "parameters": [ { @@ -1032,7 +1055,9 @@ "get": { "operationId": "downloadFile", "summary": "Download File", - "description": "Download the current file bytes from a workspace. A generated document is served as its compiled artifact, so it answers `409` while that artifact is still compiling and `413` if it renders past the size ceiling. Downloading records an audit event, so it is not a safe read. A `HEAD` skips the effect but is authorized exactly as the `GET` is, so it answers `400`, `401`, `403`, or `404` wherever the `GET` would and an empty `200` otherwise. Skipping the effect means skipping the read that produces the payload, so that `200` carries none of the response headers documented below — it answers whether the `GET` would be allowed, not what the `GET` would return. In particular a `HEAD` does not report `Content-Length`, so it cannot be used to size a download in advance; read the size from the file resource instead.", + "description": "Download current file bytes. Generated documents use compiled artifacts, returning `409` while compiling and `413` above the rendered-size ceiling. Downloading records an audit event. `HEAD` checks access with the same authorization as `GET` but skips side effects, returning an empty `200` without payload headers on success. `HEAD` omits `Content-Length`; use file metadata to size downloads.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "files.download", + "x-oauth-scope": "api:read", "tags": ["Files"], "parameters": [ { @@ -1125,7 +1150,9 @@ "delete": { "operationId": "deleteFile", "summary": "Delete File", - "description": "Archive a workspace file. This is a soft delete: the file stops appearing in the default listing and is no longer readable through the API, but its stored bytes are never removed. Archiving an already-archived file is a `404`, not a no-op. List archived files with `GET /files?scope=archived`, and reverse the delete with `POST /files/{fileId}/restore`.", + "description": "Archive a workspace file, retaining its stored bytes and removing API read access. List Files with `scope=archived` finds it; Restore File recovers it. Archiving an already archived file returns `404`.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.delete", + "x-oauth-scope": "api:write", "tags": ["Files"], "parameters": [ { @@ -1202,7 +1229,9 @@ "patch": { "operationId": "renameFile", "summary": "Rename File", - "description": "Rename a workspace file without changing its containing folder.", + "description": "Rename a workspace file without changing its containing folder.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.rename", + "x-oauth-scope": "api:write", "tags": ["Files"], "parameters": [ { @@ -1289,7 +1318,9 @@ "post": { "operationId": "restoreFile", "summary": "Restore File", - "description": "Reverse a soft delete and return the file to the workspace. Not a pure undo: the file comes back at the workspace root, and gains a `_restored` suffix when another file there already holds its name, so read `folderPath` and `name` off the response. Restoring an already-active file returns it unchanged, so a retry is safe. An archived workspace is a `400`, and a name the restore could not free is a `409`.", + "description": "Restore an archived file to the workspace root. Name collisions add a `_restored` suffix; use the returned `name` and `folderPath`. An active file returns unchanged. An archived workspace returns `400`; an unresolved name collision returns `409`.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.restore", + "x-oauth-scope": "api:write", "tags": ["Files"], "parameters": [ { @@ -1376,7 +1407,9 @@ "get": { "operationId": "getFile", "summary": "Get File Metadata", - "description": "Return file metadata together with the nullable current public-share state.", + "description": "Get file metadata and its public-share configuration. The `share` field is null when the file has never been shared.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "files.read_metadata", + "x-oauth-scope": "api:read", "tags": ["Files"], "parameters": [ { @@ -1467,7 +1500,9 @@ "get": { "operationId": "listAuditLogs", "summary": "List Audit Logs", - "description": "List an organization audit trail with filters and opaque cursor pagination. Requires an Enterprise subscription and organization admin or owner access. A workspace API key is rejected with `403`; use a personal API key.", + "description": "List an organization audit trail with filters and opaque cursor pagination. Requires an Enterprise subscription and organization admin or owner access. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "audit_logs.list", + "x-oauth-scope": "api:read", "tags": ["Audit Logs"], "parameters": [ { @@ -1641,7 +1676,9 @@ "get": { "operationId": "getAuditLog", "summary": "Get Audit Log", - "description": "Return one organization audit-log entry. Requires an Enterprise subscription and organization admin or owner access. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Get one organization audit-log entry. Requires an Enterprise subscription and organization admin or owner access. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "audit_logs.read_detail", + "x-oauth-scope": "api:read", "tags": ["Audit Logs"], "parameters": [ { @@ -1717,7 +1754,9 @@ "post": { "operationId": "moveFileItems", "summary": "Move Files", - "description": "Move up to 1,000 files to a canonical folder path or the workspace root.", + "description": "Move up to 1,000 files to a folder path or the workspace root.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.move", + "x-oauth-scope": "api:write", "tags": ["Files"], "requestBody": { "required": true, @@ -1789,7 +1828,9 @@ "get": { "operationId": "getFileShare", "summary": "Get File Share", - "description": "Return the nullable current public-share configuration for a file. A file that has never been shared returns `data: null` rather than a 404; a share that was created and later disabled is still returned, with `isActive: false`.", + "description": "Get a file's public-share configuration. An unshared file returns `data: null`; a disabled share returns its configuration with `isActive: false`.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "files.share.read", + "x-oauth-scope": "api:read", "tags": ["Files"], "parameters": [ { @@ -1866,7 +1907,9 @@ "patch": { "operationId": "upsertFileShare", "summary": "Enable or Disable File Share", - "description": "Create or partially update a server-tokenized public share. Only `isActive` is required; each other field states what enabling a mode does to it. Enabling any mode other than `public` on a file that has never been shared must carry its credential in the same request. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Create or update a file's public share. `isActive` is required; other fields describe their behavior when access modes change. Enabling a protected mode on a previously unshared file requires its credential in the same request. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.share.update", + "x-oauth-scope": "api:write", "tags": ["Files"], "parameters": [ { @@ -1950,7 +1993,9 @@ "patch": { "operationId": "editFileContent", "summary": "Edit File Content", - "description": "Change part of a text file in place, leaving the rest untouched. `PUT` on this path replaces the whole file; this is the partial counterpart. `search_replace` matches exact text and requires one match unless `replaceAll` is true. `replace_between`, `insert_after`, and `delete_between` match complete lines after trimming surrounding whitespace, so edits remain stable when unrelated changes move the target to another line. Anchored replacement preserves both boundary lines; insertion preserves its anchor; deletion removes the start anchor and preserves the end anchor. Use `occurrence` when an anchor line repeats. Only files whose stored bytes are UTF-8 text can be edited: a PDF or DOCX answers `400`. A concurrent write answers `409`, and retrying means re-reading first.", + "description": "Edit part of a UTF-8 file; use Replace File Content to replace it entirely. Search-and-replace requires one exact match unless `replaceAll` is true. Anchored modes match trimmed complete lines; their input descriptions specify boundary handling. Non-UTF-8 files return `400`. Concurrent writes return `409`; re-read before retrying.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.update_content", + "x-oauth-scope": "api:write", "tags": ["Files"], "parameters": [ { @@ -2038,7 +2083,9 @@ "put": { "operationId": "updateFileContent", "summary": "Replace File Content", - "description": "Replace the complete contents of an existing file from UTF-8 or base64 input.", + "description": "Replace the complete contents of an existing file from UTF-8 or base64 input.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.update_content", + "x-oauth-scope": "api:write", "tags": ["Files"], "parameters": [ { @@ -2122,7 +2169,9 @@ "get": { "operationId": "searchFileContent", "summary": "Search File Content", - "description": "Search the indexed text of active workspace files and return each matching line with its file id and line number. `folderPaths` confines the search to one or more folder trees, which also narrows the reported coverage, so `complete` and `indexStatus` describe the folders searched rather than the whole workspace. Coverage matters: the index is built asynchronously, so when `complete` is `false` a term that was not found is **unknown rather than absent**, and acting on the absence risks creating a duplicate of something already stored. `truncated` separately reports that more matches exist beyond `maxResults`.", + "description": "Search indexed text in active workspace files and return matching lines with file IDs and line numbers. `folderPaths` limits both results and reported coverage. Missing matches are inconclusive if `complete` is false or `indexStatus.skippedFiles` or `indexStatus.partialFiles` is nonzero. `truncated` means additional matches exist beyond `maxResults`.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "files.search_content", + "x-oauth-scope": "api:read", "tags": ["Files"], "parameters": [ { @@ -2262,7 +2311,9 @@ "post": { "operationId": "bulkDeleteFiles", "summary": "Delete Files", - "description": "Delete up to 1,000 workspace files in one operation. This is the same soft delete as `DELETE /api/v2/files/{fileId}`: files are archived, not erased, and `POST /api/v2/files/{fileId}/restore` reverses each one.", + "description": "Archive up to 1,000 workspace files while retaining their stored bytes. Use Restore File to recover each file.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.delete", + "x-oauth-scope": "api:write", "tags": ["Files"], "requestBody": { "required": true, @@ -2331,7 +2382,9 @@ "get": { "operationId": "listFilesFolders", "summary": "List Folders", - "description": "List workspace file folders with optional parent-path filtering and sorting. Pass `scope=archived` to list folders a recursive `DELETE` soft-deleted, which is how a caller finds a path to hand to `POST /api/v2/files/folders/restore`. The bounded set is returned in one page; `nextCursor` is always null.", + "description": "List workspace file folders with parent-path filtering and sorting. Use `scope=archived` to find paths accepted by Restore Folder. Returns the complete set in one page; `nextCursor` is always null.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "files.folders.list", + "x-oauth-scope": "api:read", "tags": ["Files"], "parameters": [ { @@ -2350,9 +2403,9 @@ "name": "parentPath", "in": "query", "required": false, - "description": "Restrict results to direct children of this parent path. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", + "description": "Restrict results to direct children of this parent path. Unknown folder paths contribute no matches.", "schema": { - "description": "Restrict results to direct children of this parent path. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", + "description": "Restrict results to direct children of this parent path. Unknown folder paths contribute no matches.", "$ref": "#/components/schemas/FolderPathInput" } }, @@ -2489,7 +2542,9 @@ "post": { "operationId": "createFilesFolder", "summary": "Create Folder", - "description": "Create a canonical folder path in a workspace.", + "description": "Create a folder at the supplied workspace path.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.folders.create", + "x-oauth-scope": "api:write", "tags": ["Files"], "requestBody": { "required": true, @@ -2559,7 +2614,9 @@ "patch": { "operationId": "relocateFilesFolder", "summary": "Rename or Move Folder", - "description": "Rename or move a folder and atomically rewrite descendant canonical paths.", + "description": "Rename or move a folder and atomically update all descendant paths.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.folders.update", + "x-oauth-scope": "api:write", "tags": ["Files"], "requestBody": { "required": true, @@ -2629,7 +2686,9 @@ "delete": { "operationId": "deleteFilesFolder", "summary": "Delete Folder", - "description": "Delete a folder, optionally including every nested file and folder.", + "description": "Archive an empty folder, or set `recursive=true` to archive its files and subfolders. Use Restore Folder to recover the archived contents.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.folders.delete", + "x-oauth-scope": "api:write", "tags": ["Files"], "parameters": [ { @@ -2733,7 +2792,9 @@ "post": { "operationId": "restoreFilesFolder", "summary": "Restore Folder", - "description": "Restore a soft-deleted folder and everything archived with it. `DELETE /api/v2/files/folders` archives recursively, so this is what makes a recursive delete recoverable: without it the archived files stay visible through `GET /api/v2/files?scope=archived` but the folder structure cannot be rebuilt. Address the folder by the path reported by `GET /api/v2/files/folders?scope=archived`; a path that is not archived answers `404`.", + "description": "Restore a folder and the files and subfolders archived with it. Use the path from List Folders with `scope=archived`. A path that is not archived returns `404`.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.folders.restore", + "x-oauth-scope": "api:write", "tags": ["Files"], "requestBody": { "required": true, @@ -2809,6 +2870,12 @@ "in": "header", "name": "X-API-Key", "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description." + }, + "oauthBearer": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "OAuth 2.0 access token", + "description": "A Sim OAuth access token obtained by a registered client through the authorization-code flow. Each operation declares its required scope: api:read permits reads and searches; api:write also permits changes and execution and implies api:read. Scope requirements follow the application operation, independent of HTTP method or workspace role." } }, "headers": { @@ -2905,7 +2972,7 @@ } }, "Unauthorized": { - "description": "The API key is missing or invalid.", + "description": "The API credential is missing or invalid.", "content": { "application/json": { "schema": { @@ -2914,7 +2981,7 @@ "example": { "error": { "code": "UNAUTHORIZED", - "message": "API key required" + "message": "Authentication required" } } } @@ -3082,6 +3149,49 @@ } }, "schemas": { + "V2ActionableForbiddenDetails": { + "type": "object", + "properties": { + "code": { + "$ref": "#/components/schemas/V2ForbiddenDetailCode" + } + }, + "required": ["code"], + "additionalProperties": { + "description": "Additional context for this refusal." + }, + "title": "Actionable forbidden details", + "description": "Machine-readable cause and optional context for an actionable `403` response." + }, + "V2ForbiddenDetailCode": { + "type": "string", + "enum": [ + "INSUFFICIENT_WORKSPACE_ROLE", + "PERSONAL_API_KEYS_DISABLED", + "WORKSPACE_KEY_OPERATION_NOT_PERMITTED", + "PRINCIPAL_KIND_NOT_PERMITTED", + "ORGANIZATION_MEMBERSHIP_REQUIRED", + "ORGANIZATION_ADMIN_REQUIRED", + "ENTERPRISE_PLAN_REQUIRED", + "ORGANIZATION_PLAN_REQUIRED", + "AUDIT_LOGS_DISABLED", + "SKILL_EDITOR_ACCESS_REQUIRED", + "SECRET_ADMIN_ACCESS_REQUIRED", + "WORKSPACE_RESOURCE_LIMIT_REACHED", + "PUBLIC_SHARING_NOT_ALLOWED", + "CREDENTIAL_ADMIN_ACCESS_REQUIRED", + "MCP_SERVER_URL_NOT_ALLOWED", + "WORKSPACE_PLAN_CAPABILITY_REQUIRED", + "CHAT_AUTH_MODE_NOT_PERMITTED", + "CONNECTOR_MANAGED_RESOURCE_READ_ONLY", + "PERMISSION_GROUP_CAPABILITY_BLOCKED", + "INTEGRATION_NOT_ALLOWED", + "INSUFFICIENT_SCOPE", + "SCIM_MANAGED_MEMBERSHIP" + ], + "title": "Forbidden detail code", + "description": "Stable cause code for an actionable `403` response." + }, "V2Error": { "type": "object", "properties": { @@ -3097,7 +3207,15 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `ORGANIZATION_PLAN_REQUIRED` — The organization has no active organization subscription (Pro for Teams, Max for Teams, or Enterprise).\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\n- `WORKSPACE_PLAN_CAPABILITY_REQUIRED` — The workspace's plan does not include a capability this request depends on. The message names the capability; upgrading the workspace's plan is the remedy.\n- `CHAT_AUTH_MODE_NOT_PERMITTED` — The workspace's permission group does not allow the chat authentication mode the request selected. A mode already saved on the deployment may still be re-saved; changing to a disallowed one cannot.\n- `CONNECTOR_MANAGED_RESOURCE_READ_ONLY` — This resource is managed by a knowledge base connector and cannot be edited directly. Change it at the source and re-sync, or exclude the document from the connector.\n- `PERMISSION_GROUP_CAPABILITY_BLOCKED` — The caller's permission group does not allow this capability. The message names it; an organization admin controls the group.\n- `INTEGRATION_NOT_ALLOWED` — The integration this request names is outside the workspace's allowed set. An organization admin controls the permission group's integration allowlist, and a self-hosted deployment can narrow it further with ALLOWED_INTEGRATIONS." + "description": "Structured error context whose keys depend on the error. Actionable `403` responses use the `V2ActionableForbiddenDetails` shape; validation failures may return issue arrays instead.", + "anyOf": [ + { + "$ref": "#/components/schemas/V2ActionableForbiddenDetails" + }, + { + "description": "Other structured context defined by the specific error." + } + ] } }, "required": ["code", "message"], @@ -3167,7 +3285,7 @@ "uploadedByEmail": { "type": "string", "format": "email", - "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", + "pattern": "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$", "description": "Current email address of the uploader.", "examples": ["jane@example.com"] }, @@ -3414,7 +3532,7 @@ "url": { "type": "string", "format": "uri", - "description": "Signed URL to which the file bytes are uploaded. Send the bytes with `PUT` to this URL, including exactly the headers in `headers` and nothing that alters the body. The URL is signed and self-describing — construct it from this field only, never by hand.\n\n**Where this URL points depends on the deployment, and so does what answers you.** When Sim stores objects itself the URL is Sim's own data plane: success is `204` with an empty body, and a failure is the same `{ \"error\": { \"code\", \"message\" } }` envelope as every other v2 response — `400` when the body does not match the size or content type the session was created for, `403` when the token is invalid, expired, or belongs to another session, and `409` when the session is no longer accepting bytes. When object storage is configured — S3, Google Cloud Storage, or Azure Blob — the URL is that provider's own presigned URL, and the provider answers directly: treat **any `2xx` as success** (S3 and GCS answer `200`, Azure `201`), and on failure expect the provider's error document, typically XML, not the v2 envelope. Do not branch on `204` and do not parse a failure as JSON." + "description": "Signed URL to which the file bytes are uploaded. Upload bytes with `PUT` and exactly the supplied headers; never construct or modify the signed URL. Treat any `2xx` as success. Sim-hosted URLs return an empty `204` and v2 JSON errors. Object-storage URLs may return `200` or `201` and provider-specific errors, often XML." }, "headers": { "type": "object", @@ -3568,7 +3686,7 @@ "url": { "type": "string", "format": "uri", - "description": "Signed URL for this upload part. Send the bytes with `PUT` to this URL, including exactly the headers in `headers` and nothing that alters the body. The URL is signed and self-describing — construct it from this field only, never by hand.\n\n**Where this URL points depends on the deployment, and so does what answers you.** When Sim stores objects itself the URL is Sim's own data plane: success is `204` with an empty body, and a failure is the same `{ \"error\": { \"code\", \"message\" } }` envelope as every other v2 response — `400` when the body does not match the size or content type the session was created for, `403` when the token is invalid, expired, or belongs to another session, and `409` when the session is no longer accepting bytes. When object storage is configured — S3, Google Cloud Storage, or Azure Blob — the URL is that provider's own presigned URL, and the provider answers directly: treat **any `2xx` as success** (S3 and GCS answer `200`, Azure `201`), and on failure expect the provider's error document, typically XML, not the v2 envelope. Do not branch on `204` and do not parse a failure as JSON.\n\nYou do not need to retain the `ETag` each part upload returns. Unlike a raw S3 multipart flow, completion takes no request body: Sim lists the uploaded parts from the provider itself and reads their entity tags there, so `POST .../complete` only has to happen after every part has been sent." + "description": "Signed URL for this upload part. Upload bytes with `PUT` and exactly the supplied headers; never construct or modify the signed URL. Treat any `2xx` as success. Sim-hosted URLs return an empty `204` and v2 JSON errors. Object-storage URLs may return `200` or `201` and provider-specific errors, often XML. Do not retain part `ETag` values; after every part succeeds, call the completion endpoint without a request body." }, "headers": { "type": "object", @@ -4029,7 +4147,7 @@ "uploadedByEmail": { "type": "string", "format": "email", - "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", + "pattern": "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$", "description": "Current email address of the uploader.", "examples": ["jane@example.com"] }, @@ -4086,7 +4204,7 @@ ], "additionalProperties": false, "title": "File metadata", - "description": "Workspace file metadata enriched with nullable public-share state." + "description": "Workspace file metadata and current public-share configuration." }, "V2FileMetadataResponse": { "type": "object", @@ -4207,7 +4325,7 @@ "type": "null" } ], - "description": "Identifier of the affected resource. Always null when `resourceType` is `folder`: folders are addressed by canonical path on this API, so their internal identifiers are withheld rather than published as an id no other endpoint accepts." + "description": "Affected resource ID. Null for folder events, which identify folders by path." }, "resourceName": { "anyOf": [ @@ -4232,7 +4350,7 @@ "description": "Human-readable description of the action." }, "metadata": { - "description": "Arbitrary per-action JSON metadata. Internal folder identifiers are stripped at every nesting level, for the same reason `resourceId` is null on a folder entry." + "description": "Additional JSON details specific to the action." }, "createdAt": { "type": "string", @@ -4778,7 +4896,7 @@ }, "complete": { "type": "boolean", - "description": "True when no file in the searched scope is still pending or failed indexing. It does NOT cover `skippedFiles` (never indexed, such as binaries) or `partialFiles` (indexed only in part), so a missing match is authoritative only when all three are clear. Treat any of them as nonzero meaning unknown rather than absent." + "description": "True when no files in the searched scope have pending or failed indexing. Missing matches remain inconclusive unless this is true and both `indexStatus.skippedFiles` and `indexStatus.partialFiles` are zero." }, "indexStatus": { "type": "object", diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index 999ba3d8732..eddf2f2f991 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -29,6 +29,9 @@ "security": [ { "apiKey": [] + }, + { + "oauthBearer": [] } ], "paths": { @@ -36,7 +39,9 @@ "get": { "operationId": "listKnowledgeBases", "summary": "List Knowledge Bases", - "description": "List knowledge bases in a workspace with lifecycle scope, folder filtering, search, sorting, and opaque cursor pagination. `scope` defaults to `active`; pass `archived` to list knowledge bases a `DELETE` archived, each carrying the `deletedAt` instant it was archived, and recover one with `POST /api/v2/knowledge/{knowledgeBaseId}/restore`. A workspace folder tree over 10,000 folders is a `413`.", + "description": "List active knowledge bases in a workspace with folder filtering, search, sorting, and cursor pagination. Use `scope=archived` to find knowledge bases available for restoration. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "knowledge.list", + "x-oauth-scope": "api:read", "tags": ["Knowledge Bases"], "parameters": [ { @@ -55,10 +60,10 @@ "name": "scope", "in": "query", "required": false, - "description": "Which lifecycle set to list: `active` (default) for live knowledge bases, `archived` for knowledge bases a `DELETE` archived and `POST /knowledge/{knowledgeBaseId}/restore` can bring back. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.", + "description": "Lifecycle scope: active or archived knowledge bases. Use Restore Knowledge Base to recover archived entries. Folder paths resolve only active folders, so filtering by an archived folder returns no matches.", "schema": { "default": "active", - "description": "Which lifecycle set to list: `active` (default) for live knowledge bases, `archived` for knowledge bases a `DELETE` archived and `POST /knowledge/{knowledgeBaseId}/restore` can bring back. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.", + "description": "Lifecycle scope: active or archived knowledge bases. Use Restore Knowledge Base to recover archived entries. Folder paths resolve only active folders, so filtering by an archived folder returns no matches.", "type": "string", "enum": ["active", "archived"] } @@ -67,9 +72,9 @@ "name": "folderPath", "in": "query", "required": false, - "description": "Restrict results to knowledge bases in this folder. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", + "description": "Restrict results to knowledge bases in this folder. Unknown folder paths contribute no matches.", "schema": { - "description": "Restrict results to knowledge bases in this folder. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", + "description": "Restrict results to knowledge bases in this folder. Unknown folder paths contribute no matches.", "$ref": "#/components/schemas/FolderPathInput" } }, @@ -185,7 +190,9 @@ "post": { "operationId": "createKnowledgeBase", "summary": "Create Knowledge Base", - "description": "Create a knowledge base in a workspace with optional folder placement and chunking configuration. An unknown `folderPath` is a `404`. A workspace folder tree over 10,000 folders is a `413`.", + "description": "Create a knowledge base in a workspace with optional folder placement and chunking configuration. An unknown `folderPath` returns `404`. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "knowledge.create", + "x-oauth-scope": "api:write", "tags": ["Knowledge Bases"], "requestBody": { "required": true, @@ -257,7 +264,9 @@ "get": { "operationId": "getKnowledgeBase", "summary": "Get Knowledge Base", - "description": "Retrieve a knowledge base by identifier. Inaccessible knowledge bases are reported as not found. A workspace folder tree over 10,000 folders is a `413`.", + "description": "Get a knowledge base's metadata and document counts. Inaccessible knowledge bases return `404`. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "knowledge.read", + "x-oauth-scope": "api:read", "tags": ["Knowledge Bases"], "parameters": [ { @@ -334,7 +343,9 @@ "patch": { "operationId": "updateKnowledgeBase", "summary": "Update Knowledge Base", - "description": "Update a knowledge base name, description, chunking configuration, or folder placement. A workspace folder tree over 10,000 folders is a `413`.", + "description": "Update a knowledge base's name, description, chunking configuration, or folder placement. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "knowledge.update", + "x-oauth-scope": "api:write", "tags": ["Knowledge Bases"], "parameters": [ { @@ -417,7 +428,9 @@ "delete": { "operationId": "deleteKnowledgeBase", "summary": "Delete Knowledge Base", - "description": "Delete a knowledge base and its documents.", + "description": "Archive a knowledge base, its documents, and its connectors, pausing synchronization. Use List Knowledge Bases with `scope=archived` to find it and Restore Knowledge Base to recover it.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "knowledge.delete", + "x-oauth-scope": "api:write", "tags": ["Knowledge Bases"], "parameters": [ { @@ -493,7 +506,9 @@ "get": { "operationId": "listKnowledgeConnectors", "summary": "List Knowledge Connectors", - "description": "List external sources connected to a knowledge base with opaque cursor pagination. Stored API keys and encrypted secret material are never returned. A workspace API key is rejected with `403`; use a personal API key.", + "description": "List external sources connected to a knowledge base with cursor pagination. Stored API keys are never returned. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "knowledge.connectors.list", + "x-oauth-scope": "api:read", "tags": ["Knowledge Bases"], "parameters": [ { @@ -616,7 +631,9 @@ "post": { "operationId": "createKnowledgeConnector", "summary": "Create Knowledge Connector", - "description": "Validate and connect an external source, then queue its initial synchronization. The apiKey field is write-only and is never returned. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Validate and connect an external source, then queue its initial synchronization. The `apiKey` field is never returned. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "knowledge.connectors.create", + "x-oauth-scope": "api:write", "tags": ["Knowledge Bases"], "parameters": [ { @@ -701,7 +718,9 @@ "get": { "operationId": "getKnowledgeConnector", "summary": "Get Knowledge Connector", - "description": "Retrieve one connector and its ten most recent synchronization attempts. Stored API keys and encrypted secret material are never returned. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Get one connector and its ten most recent synchronization attempts. Stored API keys are never returned. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "knowledge.connectors.read", + "x-oauth-scope": "api:read", "tags": ["Knowledge Bases"], "parameters": [ { @@ -787,7 +806,9 @@ "patch": { "operationId": "updateKnowledgeConnector", "summary": "Update Knowledge Connector", - "description": "Update connector source configuration, schedule, or active state. Replacing source configuration on a runnable connector queues an immediate synchronization; paused connectors retain the change without synchronizing until resumed. Source configuration cannot be replaced while synchronization is already in progress. Authentication material cannot be changed through this operation. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Update connector source configuration, schedule, or active state. Replacing source configuration on a runnable connector queues an immediate synchronization; paused connectors retain the change without synchronizing until resumed. Source configuration cannot be replaced while synchronization is already in progress. Authentication material cannot be changed through this operation. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "knowledge.connectors.update", + "x-oauth-scope": "api:write", "tags": ["Knowledge Bases"], "parameters": [ { @@ -881,7 +902,9 @@ "delete": { "operationId": "deleteKnowledgeConnector", "summary": "Delete Knowledge Connector", - "description": "Delete a connector and optionally its synchronized documents. Documents are retained by default. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Delete a connector and optionally its synchronized documents. Documents are retained by default. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "knowledge.connectors.delete", + "x-oauth-scope": "api:write", "tags": ["Knowledge Bases"], "parameters": [ { @@ -979,7 +1002,9 @@ "post": { "operationId": "syncKnowledgeConnector", "summary": "Sync Knowledge Connector", - "description": "Queue a connector synchronization. Rehydration forces existing documents to be fetched and indexed again. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Queue a connector synchronization. Rehydration forces existing documents to be fetched and indexed again. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "knowledge.connectors.sync", + "x-oauth-scope": "api:write", "tags": ["Knowledge Bases"], "parameters": [ { @@ -1075,7 +1100,9 @@ "get": { "operationId": "listKnowledgeConnectorDocuments", "summary": "List Knowledge Connector Documents", - "description": "List documents produced by one connector with opaque cursor pagination. Excluded documents are omitted unless explicitly requested. A workspace API key is rejected with `403`; use a personal API key.", + "description": "List documents produced by one connector with opaque cursor pagination. Excluded documents are omitted unless explicitly requested. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "knowledge.connectors.documents.list", + "x-oauth-scope": "api:read", "tags": ["Knowledge Bases"], "parameters": [ { @@ -1195,7 +1222,9 @@ "patch": { "operationId": "updateKnowledgeConnectorDocuments", "summary": "Update Knowledge Connector Documents", - "description": "Exclude connector documents from knowledge search or restore previously excluded documents. Only documents produced by the selected connector can change. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Exclude connector documents from knowledge search or restore previously excluded documents. Only documents produced by the selected connector can change. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "knowledge.connectors.documents.update", + "x-oauth-scope": "api:write", "tags": ["Knowledge Bases"], "parameters": [ { @@ -1288,7 +1317,9 @@ "post": { "operationId": "searchKnowledge", "summary": "Search Knowledge", - "description": "Search one or more knowledge bases with semantic vector retrieval, optional hybrid full-text retrieval, and structured tag filters. Every result names the `knowledgeBaseId` it came from. A request body over 2 MiB is a `413`.", + "description": "Search one or more knowledge bases with semantic vector retrieval, optional hybrid full-text retrieval, and structured tag filters. Every result names the `knowledgeBaseId` it came from. A request body over 2 MiB is a `413`. Reranking returns `409` when the stored results cannot pass secret-provenance enforcement.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "knowledge.search", + "x-oauth-scope": "api:read", "tags": ["Knowledge Bases"], "requestBody": { "required": true, @@ -1338,6 +1369,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, "413": { "$ref": "#/components/responses/PayloadTooLarge" }, @@ -1360,7 +1394,9 @@ "get": { "operationId": "listKnowledgeTags", "summary": "List Tags", - "description": "List the knowledge base's tag vocabulary: each tag's display name, the slot it is stored in, and its field type. Filters and document reads use display names; document writes address slots. The bounded set is returned in one page; `nextCursor` is always null.", + "description": "List the knowledge base's tag definitions with display names, write slots, and field types. Filters and document reads use display names; document writes use slots. Returns the complete set in one page; `nextCursor` is always null.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "knowledge.tags.list", + "x-oauth-scope": "api:read", "tags": ["Knowledge Bases"], "parameters": [ { @@ -1434,7 +1470,9 @@ "post": { "operationId": "createKnowledgeTag", "summary": "Create Tag", - "description": "Define one tag on a knowledge base; use `PUT` on this path to declare several at once. Define a tag here, write its `tagSlot` on a document with `PATCH /api/v2/knowledge/{knowledgeBaseId}/documents/{documentId}`, then filter by its `displayName` on the document list or on search. Omit `tagSlot` to take the next free slot for the field type; a field type with no free slot left is a `400` naming it, since the remedy is a different type or a deleted definition rather than a retry. A `tagSlot` already taken, or a `displayName` already defined on this knowledge base, is a `409` naming which of the two to change. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Create a tag definition. Write document values by `tagSlot` and filter by `displayName`. Omitting `tagSlot` selects a free slot; exhaustion returns `400`. An occupied slot or duplicate name returns `409`. Use Bulk Save Tag Definitions for multiple definitions. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "knowledge.tags.create", + "x-oauth-scope": "api:write", "tags": ["Knowledge Bases"], "parameters": [ { @@ -1517,7 +1555,9 @@ "put": { "operationId": "bulkSaveKnowledgeTagDefinitions", "summary": "Bulk Save Tag Definitions", - "description": "Declare, in one request, several of the knowledge base's tag definitions. `POST` on this path defines exactly one tag; this is the same write over a list, and every slot the body names is written to the declaration it carries while slots it does not name are left alone. Updating an existing definition requires naming its current name in `originalDisplayName`; that is the only form that edits one in place. Without it the entry is a create, and a requested `tagSlot` another name already holds is refused in `errors` — it is neither overwritten nor relocated to a different slot, so an explicitly requested slot always means that slot or an error. A create whose `displayName` already exists is refused in `errors`. Per-definition failures are reported in `errors` and still answer `200`. This writes the vocabulary, not one document's tag values — set those with `PATCH /api/v2/knowledge/{knowledgeBaseId}/documents/{documentId}`. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Create or update tag definitions, preserving unspecified slots. Updates require `originalDisplayName`; other entries create tags. Slot and name conflicts appear in per-definition `errors` with HTTP `200`, leaving conflicting values unchanged. Use Update Document to set tag values. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "knowledge.tags.bulk_save", + "x-oauth-scope": "api:write", "tags": ["Knowledge Bases"], "parameters": [ { @@ -1597,7 +1637,9 @@ "delete": { "operationId": "deleteKnowledgeTagDefinitions", "summary": "Delete Tag Definitions", - "description": "Remove tag definitions from the knowledge base. `unused` defaults to `true`, which removes only the definitions no document still carries a value for — the recoverable half, since a definition with nothing behind it can simply be redefined. Pass `unused=false` to delete every definition on the knowledge base, which also clears its slot on every document and chunk and is not recoverable. Delete one definition at a time with `DELETE /api/v2/knowledge/{knowledgeBaseId}/tags/{tagId}`. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Delete unused tag definitions by default. With `unused=false`, permanently delete all definitions and their values from documents and chunks. Use Delete Tag to remove one definition. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "knowledge.tags.cleanup", + "x-oauth-scope": "api:write", "tags": ["Knowledge Bases"], "parameters": [ { @@ -1684,7 +1726,9 @@ "get": { "operationId": "listKnowledgeDocuments", "summary": "List Documents", - "description": "List documents in a knowledge base with filename search, state filtering, tag filtering, sorting, and opaque cursor pagination. Tag values are keyed by display name; resolve those to write slots with `GET /api/v2/knowledge/{knowledgeBaseId}/tags`.", + "description": "List documents with filename search, state and tag filters, sorting, and cursor pagination. Tag values use display names; use List Tags to resolve the slots required for writes.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "knowledge.documents.list", + "x-oauth-scope": "api:read", "tags": ["Knowledge Bases"], "parameters": [ { @@ -1851,7 +1895,9 @@ "patch": { "operationId": "bulkUpdateKnowledgeDocuments", "summary": "Bulk Enable or Disable Documents", - "description": "Enable or disable many documents in one request, either by identifier or, with `selectAll`, every document in the knowledge base. Bulk delete is not offered; delete documents one at a time with `DELETE /api/v2/knowledge/{knowledgeBaseId}/documents/{documentId}`. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Enable or disable selected documents, or use `selectAll` for the entire knowledge base. Use Delete Document to remove documents individually. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "knowledge.documents.bulk", + "x-oauth-scope": "api:write", "tags": ["Knowledge Bases"], "parameters": [ { @@ -1931,7 +1977,9 @@ "post": { "operationId": "uploadKnowledgeDocument", "summary": "Upload Document", - "description": "Upload one document as multipart form data. Processing continues asynchronously after the document is accepted.", + "description": "Upload one document as multipart form data. Processing continues asynchronously after the document is accepted.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "knowledge.documents.upload", + "x-oauth-scope": "api:write", "tags": ["Knowledge Bases"], "parameters": [ { @@ -2028,7 +2076,9 @@ "post": { "operationId": "createKnowledgeDocumentUpload", "summary": "Create Document Upload", - "description": "Create a resumable upload session and receive direct PUT or multipart transfer instructions.", + "description": "Create a resumable upload session and receive direct PUT or multipart transfer instructions.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "knowledge.documents.upload.create", + "x-oauth-scope": "api:write", "tags": ["Knowledge Bases"], "parameters": [ { @@ -2113,7 +2163,9 @@ "delete": { "operationId": "abortKnowledgeDocumentUpload", "summary": "Abort Document Upload", - "description": "Abort an incomplete upload and discard provider-side multipart state.", + "description": "Abort an incomplete upload session and discard its uploaded data. Completed uploads cannot be aborted.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "knowledge.documents.upload.cancel", + "x-oauth-scope": "api:write", "tags": ["Knowledge Bases"], "parameters": [ { @@ -2215,7 +2267,9 @@ "post": { "operationId": "createKnowledgeDocumentUploadPartUrls", "summary": "Create Document Upload Part URLs", - "description": "Issue short-lived signed PUT URLs for up to 100 multipart part numbers.", + "description": "Create short-lived signed PUT URLs for up to 100 multipart part numbers.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "knowledge.documents.upload.parts", + "x-oauth-scope": "api:write", "tags": ["Knowledge Bases"], "parameters": [ { @@ -2334,7 +2388,9 @@ "post": { "operationId": "completeKnowledgeDocumentUpload", "summary": "Complete Document Upload", - "description": "Verify a direct upload or assemble multipart parts, create the knowledge document, and queue asynchronous processing.", + "description": "Verify a direct upload or assemble multipart parts, create the knowledge document, and queue asynchronous processing.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "knowledge.documents.upload.complete", + "x-oauth-scope": "api:write", "tags": ["Knowledge Bases"], "parameters": [ { @@ -2436,7 +2492,9 @@ "get": { "operationId": "getKnowledgeDocument", "summary": "Get Document", - "description": "Retrieve document detail, processing state, and connector provenance.", + "description": "Get document metadata, processing status, and source connector details.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "knowledge.documents.read", + "x-oauth-scope": "api:read", "tags": ["Knowledge Bases"], "parameters": [ { @@ -2521,7 +2579,9 @@ "patch": { "operationId": "updateKnowledgeDocument", "summary": "Update Document", - "description": "Rename a document, enable or disable it for search, set any of its 17 tag slots, or requeue it for processing. Absent fields are unchanged, and derived indexing state is read-only. Resolve a tag display name to its slot with `GET /api/v2/knowledge/{knowledgeBaseId}/tags`. The returned document omits the connector provenance the detail read carries. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Rename a document, change search availability, update tag slots, or requeue processing. Omitted fields remain unchanged; indexing state is read-only. Use List Tags to resolve names to slots and Get Document for source connector details, which this response omits. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "knowledge.documents.update", + "x-oauth-scope": "api:write", "tags": ["Knowledge Bases"], "parameters": [ { @@ -2612,7 +2672,9 @@ "delete": { "operationId": "deleteKnowledgeDocument", "summary": "Delete Document", - "description": "Remove one document from a knowledge base. An uploaded document is deleted outright with its indexed chunks. A connector-backed document is instead excluded — its row and embeddings survive, but it stops being searchable and a later sync does not re-add it. Either way it no longer appears in listings or search results.", + "description": "Remove a document from listings and search. Uploaded documents and their chunks are deleted. Connector documents are excluded while retaining their stored data; later synchronization does not re-add them.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "knowledge.documents.delete", + "x-oauth-scope": "api:write", "tags": ["Knowledge Bases"], "parameters": [ { @@ -2699,7 +2761,9 @@ "get": { "operationId": "listKnowledgeFolders", "summary": "List Folders", - "description": "List folders in the knowledge-base folder tree with filtering and sorting. The bounded set is returned in one page; `nextCursor` is always null. A workspace folder tree over 10,000 folders is a `413`.", + "description": "List folders in the knowledge-base folder tree with filtering and sorting. Returns the complete set in one page; `nextCursor` is always null. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "knowledge.folders.list", + "x-oauth-scope": "api:read", "tags": ["Knowledge Bases"], "parameters": [ { @@ -2718,9 +2782,9 @@ "name": "parentPath", "in": "query", "required": false, - "description": "Restrict results to direct children of this parent path. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", + "description": "Restrict results to direct children of this parent path. Unknown folder paths contribute no matches.", "schema": { - "description": "Restrict results to direct children of this parent path. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", + "description": "Restrict results to direct children of this parent path. Unknown folder paths contribute no matches.", "$ref": "#/components/schemas/FolderPathInput" } }, @@ -2812,7 +2876,9 @@ "post": { "operationId": "createKnowledgeFolder", "summary": "Create Folder", - "description": "Create a folder in the knowledge-base folder tree. A workspace folder tree over 10,000 folders is a `413`.", + "description": "Create a folder in the knowledge-base folder tree. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "knowledge.folders.create", + "x-oauth-scope": "api:write", "tags": ["Knowledge Bases"], "requestBody": { "required": true, @@ -2882,7 +2948,9 @@ "patch": { "operationId": "relocateKnowledgeFolder", "summary": "Rename or Move Folder", - "description": "Rename or move a folder and atomically rewrite descendant paths. A workspace folder tree over 10,000 folders is a `413`.", + "description": "Rename or move a folder and atomically rewrite descendant paths. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "knowledge.folders.relocate", + "x-oauth-scope": "api:write", "tags": ["Knowledge Bases"], "requestBody": { "required": true, @@ -2952,7 +3020,9 @@ "delete": { "operationId": "deleteKnowledgeFolder", "summary": "Delete Folder", - "description": "Delete a folder, optionally including nested folders and knowledge bases.", + "description": "Archive an empty folder, or set `recursive=true` to archive its subfolders and knowledge bases. Use Restore Knowledge Base to recover knowledge bases.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "knowledge.folders.delete", + "x-oauth-scope": "api:write", "tags": ["Knowledge Bases"], "parameters": [ { @@ -3059,7 +3129,9 @@ "post": { "operationId": "restoreKnowledgeBase", "summary": "Restore Knowledge Base", - "description": "Un-archive a soft-deleted knowledge base along with its documents and connectors. Idempotent: a knowledge base that is already active is returned unchanged with no audit entry recorded. Restoring into an archived workspace is a `409`, and a knowledge base whose folder is still archived is returned to the workspace root. A workspace folder tree over 10,000 folders is a `413`.", + "description": "Restore a knowledge base and the documents and connectors archived with it. Active knowledge bases return unchanged without a new audit event. An archived workspace returns `409`; an archived containing folder moves the restored knowledge base to the workspace root. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "knowledge.restore", + "x-oauth-scope": "api:write", "tags": ["Knowledge Bases"], "parameters": [ { @@ -3144,7 +3216,9 @@ "post": { "operationId": "addWorkspaceFilesToKnowledgeBase", "summary": "Index Workspace Files", - "description": "Index files the workspace already stores, without re-uploading their bytes. Each reference is authorized against the file it names, so a reference the caller cannot read, one over the 100 MB document limit, or one whose type is not supported is reported in `failed` while the rest are queued — a partial outcome is a `200`, not a multi-status. A queued document starts in the `pending` processing state; the entries returned here carry only its identity, so read `GET /api/v2/knowledge/{knowledgeBaseId}/documents/{documentId}` for its current state. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Queue stored workspace files for indexing without re-uploading bytes. Unreadable, unsupported, or over-100 MB files appear in `failed`; valid files are queued. Partial success returns `200`. Use Get Document to poll processing after receiving document IDs. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "knowledge.documents.add_workspace_files", + "x-oauth-scope": "api:write", "tags": ["Knowledge Bases"], "parameters": [ { @@ -3229,7 +3303,9 @@ "get": { "operationId": "listKnowledgeChunks", "summary": "List Chunks", - "description": "List the passages a document was split into, with content search, enabled filtering, sorting, and opaque cursor pagination. Tag values are projected by slot; resolve slots to display names with `GET /api/v2/knowledge/{knowledgeBaseId}/tags`. A document that has not finished processing answers `409`; the message names the status it is in. A workspace API key is rejected with `403`; use a personal API key.", + "description": "List document chunks with content search, enabled filtering, sorting, and cursor pagination. Tags use slots; use List Tags to resolve display names. Documents that have not finished processing return `409` with their current status. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "knowledge.chunks.list", + "x-oauth-scope": "api:read", "tags": ["Knowledge Bases"], "parameters": [ { @@ -3390,7 +3466,9 @@ "post": { "operationId": "createKnowledgeChunk", "summary": "Create Chunk", - "description": "Append a chunk to a document. The text is embedded before the response returns, so the chunk is searchable immediately, and it inherits the document's tag values and the next `chunkIndex`. Chunks of a connector-synced document are read-only and a write answers `403` with `error.details.code: \"CONNECTOR_MANAGED_RESOURCE_READ_ONLY\"` — change the content at the source and re-sync, or exclude the document from the connector. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Append a chunk, embedding it before the response so it is immediately searchable. It inherits the document's tags and next `chunkIndex`. Connector-synced chunks are read-only and return `403` with `error.details.code: \"CONNECTOR_MANAGED_RESOURCE_READ_ONLY\"`; change the source and re-sync, or exclude the document. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "knowledge.chunks.create", + "x-oauth-scope": "api:write", "tags": ["Knowledge Bases"], "parameters": [ { @@ -3481,7 +3559,9 @@ "patch": { "operationId": "bulkUpdateKnowledgeChunks", "summary": "Bulk Update Chunks", - "description": "Enable, disable, or delete many chunks of one document in a single request. Best-effort: an identifier naming no chunk in the document is reported in `errors` rather than failing the request. `processed` counts the chunks the operation matched, not the chunks it changed. Chunks of a connector-synced document are read-only and a write answers `403` with `error.details.code: \"CONNECTOR_MANAGED_RESOURCE_READ_ONLY\"` — change the content at the source and re-sync, or exclude the document from the connector. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Enable, disable, or delete multiple chunks in one best-effort request. Unknown chunk IDs appear in `errors` without failing the request; `processed` counts matched chunks, not changes. Connector-synced chunks are read-only and return `403` with `error.details.code: \"CONNECTOR_MANAGED_RESOURCE_READ_ONLY\"`; change the source and re-sync, or exclude the document. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "knowledge.chunks.bulk", + "x-oauth-scope": "api:write", "tags": ["Knowledge Bases"], "parameters": [ { @@ -3574,7 +3654,9 @@ "get": { "operationId": "getKnowledgeChunk", "summary": "Get Chunk", - "description": "Retrieve one chunk of a document, including the exact text that was embedded. A document that has not finished processing answers `409`; the message names the status it is in. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Get one chunk of a document, including the exact text that was embedded. Documents that have not finished processing return `409` with their current status. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "knowledge.chunks.read", + "x-oauth-scope": "api:read", "tags": ["Knowledge Bases"], "parameters": [ { @@ -3674,7 +3756,9 @@ "patch": { "operationId": "updateKnowledgeChunk", "summary": "Update Chunk", - "description": "Correct a chunk's text or take it out of search. Changing `content` re-embeds the chunk and re-derives the document's token and character counts, so the correction reaches search immediately; disabling keeps the chunk indexed. Chunks of a connector-synced document are read-only and a write answers `403` with `error.details.code: \"CONNECTOR_MANAGED_RESOURCE_READ_ONLY\"` — change the content at the source and re-sync, or exclude the document from the connector. A document that has not finished processing answers `409`; the message names the status it is in. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Correct chunk text or disable it from search. Changing `content` re-embeds immediately and recalculates document token and character counts; disabling retains the index. Connector-synced chunks are read-only and return `403` with `error.details.code: \"CONNECTOR_MANAGED_RESOURCE_READ_ONLY\"`; change the source and re-sync, or exclude the document. Documents that have not finished processing return `409` with their current status. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "knowledge.chunks.update", + "x-oauth-scope": "api:write", "tags": ["Knowledge Bases"], "parameters": [ { @@ -3779,7 +3863,9 @@ "delete": { "operationId": "deleteKnowledgeChunk", "summary": "Delete Chunk", - "description": "Permanently remove one chunk and subtract it from the document's counts. Deleting does not renumber the remaining chunks, so `chunkIndex` values stay stable but become non-contiguous. Chunks of a connector-synced document are read-only and a write answers `403` with `error.details.code: \"CONNECTOR_MANAGED_RESOURCE_READ_ONLY\"` — change the content at the source and re-sync, or exclude the document from the connector. A document that has not finished processing answers `409`; the message names the status it is in. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Permanently remove one chunk and subtract it from document counts. Remaining `chunkIndex` values stay stable and may become non-contiguous. Connector-synced chunks are read-only and return `403` with `error.details.code: \"CONNECTOR_MANAGED_RESOURCE_READ_ONLY\"`; change the source and re-sync, or exclude the document. Documents that have not finished processing return `409` with their current status. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "knowledge.chunks.delete", + "x-oauth-scope": "api:write", "tags": ["Knowledge Bases"], "parameters": [ { @@ -3881,7 +3967,9 @@ "patch": { "operationId": "updateKnowledgeTag", "summary": "Update Tag", - "description": "Rename a tag, or change the value type stored in its slot. Renaming changes the name filters and document reads use; the slot, and every value in it, is untouched. A tag's slot is fixed for its lifetime and each slot holds one kind of value, so `fieldType` can only change to another type valid for the slot the tag already occupies — anything else is a `400`, and the way to get a tag of that type is to create one. A name another tag on this knowledge base already holds is a `409`. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Rename a tag or change its slot-compatible `fieldType`. Renaming changes read and filter names without moving the slot or its values. Slots are fixed for a tag's lifetime; an incompatible type returns `400` and requires creating a new tag. A duplicate display name returns `409`. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "knowledge.tags.update", + "x-oauth-scope": "api:write", "tags": ["Knowledge Bases"], "parameters": [ { @@ -3975,7 +4063,9 @@ "delete": { "operationId": "deleteKnowledgeTag", "summary": "Delete Tag", - "description": "Remove a tag definition and clear its slot across every document and chunk in the knowledge base. Without a definition the slot has no meaning, so leaving the values would strand them under a raw slot name — this is not recoverable. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Permanently delete a tag definition and its values from every document and chunk in the knowledge base. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "knowledge.tags.delete", + "x-oauth-scope": "api:write", "tags": ["Knowledge Bases"], "parameters": [ { @@ -4066,7 +4156,9 @@ "get": { "operationId": "getNextKnowledgeTagSlot", "summary": "Get Next Tag Slot", - "description": "Report which slot a create would take for a field type, and how many are left. Advisory rather than a claim: nothing is reserved, and `POST /api/v2/knowledge/{knowledgeBaseId}/tags` assigns the same slot when `tagSlot` is omitted. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Get the next available slot and remaining capacity for a field type. This does not reserve a slot. Create Tag selects a free slot when `tagSlot` is omitted. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "knowledge.tags.read_next_slot", + "x-oauth-scope": "api:read", "tags": ["Knowledge Bases"], "parameters": [ { @@ -4155,7 +4247,9 @@ "get": { "operationId": "listKnowledgeTagUsage", "summary": "List Tag Usage", - "description": "Report how many documents and chunks carry a value for each defined tag, so a caller can tell a tag that is actually populated from one that was only declared. The bounded set is returned in one page; `nextCursor` is always null. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Count the documents and chunks with a value for each defined tag. Returns the complete set in one page; `nextCursor` is always null. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "knowledge.tags.read_usage", + "x-oauth-scope": "api:read", "tags": ["Knowledge Bases"], "parameters": [ { @@ -4236,6 +4330,12 @@ "in": "header", "name": "X-API-Key", "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description." + }, + "oauthBearer": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "OAuth 2.0 access token", + "description": "A Sim OAuth access token obtained by a registered client through the authorization-code flow. Each operation declares its required scope: api:read permits reads and searches; api:write also permits changes and execution and implies api:read. Scope requirements follow the application operation, independent of HTTP method or workspace role." } }, "headers": { @@ -4307,7 +4407,7 @@ } }, "Unauthorized": { - "description": "The API key is missing or invalid.", + "description": "The API credential is missing or invalid.", "content": { "application/json": { "schema": { @@ -4316,7 +4416,7 @@ "example": { "error": { "code": "UNAUTHORIZED", - "message": "API key required" + "message": "Authentication required" } } } @@ -4484,6 +4584,49 @@ } }, "schemas": { + "V2ActionableForbiddenDetails": { + "type": "object", + "properties": { + "code": { + "$ref": "#/components/schemas/V2ForbiddenDetailCode" + } + }, + "required": ["code"], + "additionalProperties": { + "description": "Additional context for this refusal." + }, + "title": "Actionable forbidden details", + "description": "Machine-readable cause and optional context for an actionable `403` response." + }, + "V2ForbiddenDetailCode": { + "type": "string", + "enum": [ + "INSUFFICIENT_WORKSPACE_ROLE", + "PERSONAL_API_KEYS_DISABLED", + "WORKSPACE_KEY_OPERATION_NOT_PERMITTED", + "PRINCIPAL_KIND_NOT_PERMITTED", + "ORGANIZATION_MEMBERSHIP_REQUIRED", + "ORGANIZATION_ADMIN_REQUIRED", + "ENTERPRISE_PLAN_REQUIRED", + "ORGANIZATION_PLAN_REQUIRED", + "AUDIT_LOGS_DISABLED", + "SKILL_EDITOR_ACCESS_REQUIRED", + "SECRET_ADMIN_ACCESS_REQUIRED", + "WORKSPACE_RESOURCE_LIMIT_REACHED", + "PUBLIC_SHARING_NOT_ALLOWED", + "CREDENTIAL_ADMIN_ACCESS_REQUIRED", + "MCP_SERVER_URL_NOT_ALLOWED", + "WORKSPACE_PLAN_CAPABILITY_REQUIRED", + "CHAT_AUTH_MODE_NOT_PERMITTED", + "CONNECTOR_MANAGED_RESOURCE_READ_ONLY", + "PERMISSION_GROUP_CAPABILITY_BLOCKED", + "INTEGRATION_NOT_ALLOWED", + "INSUFFICIENT_SCOPE", + "SCIM_MANAGED_MEMBERSHIP" + ], + "title": "Forbidden detail code", + "description": "Stable cause code for an actionable `403` response." + }, "V2Error": { "type": "object", "properties": { @@ -4499,7 +4642,15 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `ORGANIZATION_PLAN_REQUIRED` — The organization has no active organization subscription (Pro for Teams, Max for Teams, or Enterprise).\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\n- `WORKSPACE_PLAN_CAPABILITY_REQUIRED` — The workspace's plan does not include a capability this request depends on. The message names the capability; upgrading the workspace's plan is the remedy.\n- `CHAT_AUTH_MODE_NOT_PERMITTED` — The workspace's permission group does not allow the chat authentication mode the request selected. A mode already saved on the deployment may still be re-saved; changing to a disallowed one cannot.\n- `CONNECTOR_MANAGED_RESOURCE_READ_ONLY` — This resource is managed by a knowledge base connector and cannot be edited directly. Change it at the source and re-sync, or exclude the document from the connector.\n- `PERMISSION_GROUP_CAPABILITY_BLOCKED` — The caller's permission group does not allow this capability. The message names it; an organization admin controls the group.\n- `INTEGRATION_NOT_ALLOWED` — The integration this request names is outside the workspace's allowed set. An organization admin controls the permission group's integration allowlist, and a self-hosted deployment can narrow it further with ALLOWED_INTEGRATIONS." + "description": "Structured error context whose keys depend on the error. Actionable `403` responses use the `V2ActionableForbiddenDetails` shape; validation failures may return issue arrays instead.", + "anyOf": [ + { + "$ref": "#/components/schemas/V2ActionableForbiddenDetails" + }, + { + "description": "Other structured context defined by the specific error." + } + ] } }, "required": ["code", "message"], @@ -4624,7 +4775,7 @@ "type": "null" } ], - "description": "ISO 8601 timestamp when the knowledge base was archived by `DELETE /knowledge/{knowledgeBaseId}`, or null while the knowledge base is active. Only `GET /knowledge?scope=archived` returns knowledge bases with a non-null value.", + "description": "ISO 8601 archive timestamp, or null while active. Use List Knowledge Bases with `scope=archived` to find archived knowledge bases.", "format": "date-time", "examples": ["2026-01-16T09:00:00Z"] } @@ -6135,7 +6286,7 @@ "maximum": 100 }, "tagFilters": { - "description": "Structured tag filters, at most 10 of them. Every filter must hold, including two that name the same tag: repeating one tag narrows the result rather than widening it, matching `GET /api/v2/knowledge/{knowledgeBaseId}/documents`. To match either of two values for one tag, issue a search per value. Each filtered tag must resolve to the same slot and field type in every knowledge base selected; one missing from any of them, or defined inconsistently across them, is rejected rather than ignored, and those knowledge bases must be searched separately. List the available names with `GET /api/v2/knowledge/{knowledgeBaseId}/tags`.", + "description": "Up to 10 filters combined with AND; repeating a tag narrows results. To express OR, run separate searches. Every tag must exist with the same slot and field type in each selected knowledge base or the request is rejected. List valid names with the knowledge-base tag-list operation.", "maxItems": 10, "type": "array", "items": { @@ -6181,7 +6332,7 @@ "properties": { "id": { "type": "string", - "description": "Tag definition identifier. Published because `PATCH` and `DELETE /knowledge/{knowledgeBaseId}/tags/{tagId}` address a definition by it; without it those operations are unreachable from a list read." + "description": "Tag definition ID used by Update Tag and Delete Tag." }, "displayName": { "type": "string", @@ -6320,7 +6471,7 @@ ], "description": "Tag value; dates are ISO 8601 strings and an unset tag is null." }, - "description": "Document tag values keyed by tag display name. Writes address the same tags by slot (`tag1`..`tag7`); resolve names to slots with GET /api/v2/knowledge/{knowledgeBaseId}/tags.", + "description": "Document tag values keyed by display name. Writes use slots such as `tag1`; use List Tags to map names to slots.", "examples": [ { "category": "billing", @@ -6684,7 +6835,7 @@ "url": { "type": "string", "format": "uri", - "description": "Signed URL to which the file bytes are uploaded. Send the bytes with `PUT` to this URL, including exactly the headers in `headers` and nothing that alters the body. The URL is signed and self-describing — construct it from this field only, never by hand.\n\n**Where this URL points depends on the deployment, and so does what answers you.** When Sim stores objects itself the URL is Sim's own data plane: success is `204` with an empty body, and a failure is the same `{ \"error\": { \"code\", \"message\" } }` envelope as every other v2 response — `400` when the body does not match the size or content type the session was created for, `403` when the token is invalid, expired, or belongs to another session, and `409` when the session is no longer accepting bytes. When object storage is configured — S3, Google Cloud Storage, or Azure Blob — the URL is that provider's own presigned URL, and the provider answers directly: treat **any `2xx` as success** (S3 and GCS answer `200`, Azure `201`), and on failure expect the provider's error document, typically XML, not the v2 envelope. Do not branch on `204` and do not parse a failure as JSON." + "description": "Signed URL to which the file bytes are uploaded. Upload bytes with `PUT` and exactly the supplied headers; never construct or modify the signed URL. Treat any `2xx` as success. Sim-hosted URLs return an empty `204` and v2 JSON errors. Object-storage URLs may return `200` or `201` and provider-specific errors, often XML." }, "headers": { "type": "object", @@ -6889,7 +7040,7 @@ "url": { "type": "string", "format": "uri", - "description": "Signed URL for this upload part. Send the bytes with `PUT` to this URL, including exactly the headers in `headers` and nothing that alters the body. The URL is signed and self-describing — construct it from this field only, never by hand.\n\n**Where this URL points depends on the deployment, and so does what answers you.** When Sim stores objects itself the URL is Sim's own data plane: success is `204` with an empty body, and a failure is the same `{ \"error\": { \"code\", \"message\" } }` envelope as every other v2 response — `400` when the body does not match the size or content type the session was created for, `403` when the token is invalid, expired, or belongs to another session, and `409` when the session is no longer accepting bytes. When object storage is configured — S3, Google Cloud Storage, or Azure Blob — the URL is that provider's own presigned URL, and the provider answers directly: treat **any `2xx` as success** (S3 and GCS answer `200`, Azure `201`), and on failure expect the provider's error document, typically XML, not the v2 envelope. Do not branch on `204` and do not parse a failure as JSON.\n\nYou do not need to retain the `ETag` each part upload returns. Unlike a raw S3 multipart flow, completion takes no request body: Sim lists the uploaded parts from the provider itself and reads their entity tags there, so `POST .../complete` only has to happen after every part has been sent." + "description": "Signed URL for this upload part. Upload bytes with `PUT` and exactly the supplied headers; never construct or modify the signed URL. Treat any `2xx` as success. Sim-hosted URLs return an empty `204` and v2 JSON errors. Object-storage URLs may return `200` or `201` and provider-specific errors, often XML. Do not retain part `ETag` values; after every part succeeds, call the completion endpoint without a request body." }, "headers": { "type": "object", @@ -7057,7 +7208,7 @@ ], "description": "Tag value; dates are ISO 8601 strings and an unset tag is null." }, - "description": "Document tag values keyed by tag display name. Writes address the same tags by slot (`tag1`..`tag7`); resolve names to slots with GET /api/v2/knowledge/{knowledgeBaseId}/tags.", + "description": "Document tag values keyed by display name. Writes use slots such as `tag1`; use List Tags to map names to slots.", "examples": [ { "category": "billing", @@ -8181,7 +8332,7 @@ "properties": { "id": { "type": "string", - "description": "Tag definition identifier. Published for the same reason the vocabulary read publishes it: `PATCH` and `DELETE /knowledge/{knowledgeBaseId}/tags/{tagId}` address a definition by id, so without it a usage row cannot be acted on without a second read and a slot join.", + "description": "Tag definition ID used by Update Tag and Delete Tag.", "examples": ["7c9e6679-7425-40de-944b-e07fc1f90ae7"] }, "tagSlot": { diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json index 2b65611a2fb..7daa9e200b6 100644 --- a/apps/docs/openapi-v2-logs.json +++ b/apps/docs/openapi-v2-logs.json @@ -29,6 +29,9 @@ "security": [ { "apiKey": [] + }, + { + "oauthBearer": [] } ], "paths": { @@ -36,7 +39,9 @@ "get": { "operationId": "listLogs", "summary": "List Logs", - "description": "List workflow execution logs for a workspace with filters, selectable detail, sorting by start time, duration, cost, or status, and opaque cursor pagination. Chat and Sim-agent job runs join the sequence with `includeJobRuns=true`, which is accepted only under `sortBy=startedAt` — their cost is stored as a document and their status is not comparable, so they cannot participate in the other orderings. Each item's `files` lists only the files the run itself produced, addressed by `downloadPath`; input attachments a caller supplied are read through the files API instead. Runs are hard-deleted once they pass the payer's log retention window, so an older run is simply absent rather than reported as removed. The window is 30 days from run start on the free plan, unbounded on Pro and Team, and set per organization on Enterprise with an optional per-workspace override. A workspace folder tree over 10,000 folders is a `413`.", + "description": "List logs with filters, selectable detail, sorting, and cursor pagination. `includeJobRuns=true` includes chat and Sim-agent jobs only with `sortBy=startedAt`, because other orderings are unsupported. `files` contains only run-produced files; use the files API for input attachments. Expired runs are permanently deleted. Retention is 30 days from run start on Free, unlimited on Pro and Team, and configured per organization on Enterprise with workspace overrides. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "logs.list", + "x-oauth-scope": "api:read", "tags": ["Logs"], "parameters": [ { @@ -65,10 +70,10 @@ "name": "triggers", "in": "query", "required": false, - "description": "Comma-separated trigger types to include. An empty entry is rejected. Values are matched exactly and are case-sensitive — every recorded trigger is lowercase, so `API` matches nothing while `api` matches. The vocabulary is open: it covers the core trigger types (`manual`, `api`, `schedule`, `chat`, `webhook`, `mcp`, `copilot`, `workflow`, `custom_block`) and the provider id of any webhook trigger (`slack`, `gmail`, `github`, …), so an unrecognized member is not rejected — it selects no runs. The literal value `all` is a sentinel that disables this filter entirely, so a list containing it returns runs of every trigger type; no real trigger type is named `all`. At most 100 entries.", + "description": "Comma-separated, lowercase trigger types or webhook provider IDs. Matching is exact and case-sensitive; unknown values select no runs. An empty entry is rejected. The sentinel `all` disables this filter, even when listed with other values. At most 100 entries.", "schema": { "type": "string", - "description": "Comma-separated trigger types to include. An empty entry is rejected. Values are matched exactly and are case-sensitive — every recorded trigger is lowercase, so `API` matches nothing while `api` matches. The vocabulary is open: it covers the core trigger types (`manual`, `api`, `schedule`, `chat`, `webhook`, `mcp`, `copilot`, `workflow`, `custom_block`) and the provider id of any webhook trigger (`slack`, `gmail`, `github`, …), so an unrecognized member is not rejected — it selects no runs. The literal value `all` is a sentinel that disables this filter entirely, so a list containing it returns runs of every trigger type; no real trigger type is named `all`. At most 100 entries." + "description": "Comma-separated, lowercase trigger types or webhook provider IDs. Matching is exact and case-sensitive; unknown values select no runs. An empty entry is rejected. The sentinel `all` disables this filter, even when listed with other values. At most 100 entries." } }, { @@ -244,9 +249,9 @@ "name": "includeJobRuns", "in": "query", "required": false, - "description": "Whether Chat and Sim-agent job runs join the sequence alongside workflow runs. Job runs report `kind: \"job\"`, carry no `workflow` summary, and never carry a cost ledger. They are dropped entirely — not partially matched — whenever a filter they cannot answer is set: by workflow, workflow name, folder, model, or status. A filter therefore never means two different things across the union. Accepted only when sorting by `startedAt`: job runs record cost as a document and no comparable status, so they cannot participate in the other orderings.", + "description": "Include Chat and Sim-agent jobs alongside workflow runs. Jobs use `kind: \"job\"` and have no workflow or cost ledger. Workflow, folder, model, or status filters exclude jobs. This option is valid only when sorting by `startedAt`.", "schema": { - "description": "Whether Chat and Sim-agent job runs join the sequence alongside workflow runs. Job runs report `kind: \"job\"`, carry no `workflow` summary, and never carry a cost ledger. They are dropped entirely — not partially matched — whenever a filter they cannot answer is set: by workflow, workflow name, folder, model, or status. A filter therefore never means two different things across the union. Accepted only when sorting by `startedAt`: job runs record cost as a document and no comparable status, so they cannot participate in the other orderings.", + "description": "Include Chat and Sim-agent jobs alongside workflow runs. Jobs use `kind: \"job\"` and have no workflow or cost ledger. Workflow, folder, model, or status filters exclude jobs. This option is valid only when sorting by `startedAt`.", "type": "boolean" } }, @@ -291,10 +296,10 @@ "name": "folderPaths", "in": "query", "required": false, - "description": "Comma-separated workflow folder paths to include. At most 100 entries. A path covers its whole subtree, so `/prod` also selects runs in `/prod/nested`. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", + "description": "Comma-separated workflow folder paths, including descendants. Up to 100 paths. Unknown folder paths contribute no matches.", "schema": { "type": "string", - "description": "Comma-separated workflow folder paths to include. At most 100 entries. A path covers its whole subtree, so `/prod` also selects runs in `/prod/nested`. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error." + "description": "Comma-separated workflow folder paths, including descendants. Up to 100 paths. Unknown folder paths contribute no matches." } } ], @@ -351,7 +356,9 @@ "get": { "operationId": "getLog", "summary": "Get Log", - "description": "Retrieve the diagnostic representation of a run, including its workflow snapshot, trace spans, final output, and cost. Trace spans are pruned on their own retention schedule, so an empty `traceSpans` array does not mean the run recorded none. A workspace folder tree over 10,000 folders is a `413`. Runs are hard-deleted once they pass the payer's log retention window, so an older run is simply absent rather than reported as removed. The window is 30 days from run start on the free plan, unbounded on Pro and Team, and set per organization on Enterprise with an optional per-workspace override.", + "description": "Get a run's workflow graph, trace spans, final output, and cost. Trace spans expire separately, so an empty `traceSpans` array does not prove none were recorded. Expired runs are permanently deleted. Retention is 30 days from run start on Free, unlimited on Pro and Team, and configured per organization on Enterprise with workspace overrides. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "logs.read_detail", + "x-oauth-scope": "api:read", "tags": ["Logs"], "parameters": [ { @@ -421,7 +428,9 @@ "get": { "operationId": "getLogStats", "summary": "Get Log Statistics", - "description": "Bucketed run counts, success rate, error count, and mean latency for a workspace and for each of its workflows — the aggregate a caller would otherwise have to page every run to compute. The window spans `startDate` through `endDate` when both are supplied; an omitted edge falls back to the oldest matching run on the left and to the later of the newest matching run and now on the right. With no matching runs the right edge falls back to now and the left to 24 hours before that right edge — the trailing 24 hours when neither edge was supplied, and the 24 hours preceding `endDate` when only `endDate` was supplied. A supplied `startDate` is still used verbatim, so a `startDate` without an `endDate` yields `[startDate, now]`, which can be any width. The window is divided into exactly `segmentCount` equal buckets whose width is `max(60000, floor(windowMs / segmentCount))` milliseconds. The one-minute floor is a floor on bucket width, not on the window: when it applies, the series runs past `timeBounds.end` and the trailing buckets are empty rather than the window being compressed. A folder path covers its whole subtree. Per-workflow series are capped and `workflowsTruncated` reports whether the cap applied; the workspace totals are always computed from every workflow. Runs are hard-deleted once they pass the payer's log retention window, so an older run is simply absent rather than reported as removed. The window is 30 days from run start on the free plan, unbounded on Pro and Team, and set per organization on Enterprise with an optional per-workspace override. A workspace folder tree over 10,000 folders is a `413`.", + "description": "Get run counts, success and error counts, and latency by workspace or workflow. Default bounds span recorded runs, or the last 24 hours when empty. Buckets may extend past the end. Folder filters include descendants; `workflowsTruncated` affects series, not totals. Expired runs are permanently deleted. Retention is 30 days from run start on Free, unlimited on Pro and Team, and configured per organization on Enterprise with workspace overrides. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "logs.read_stats", + "x-oauth-scope": "api:read", "tags": ["Logs"], "parameters": [ { @@ -450,10 +459,10 @@ "name": "folderPaths", "in": "query", "required": false, - "description": "Comma-separated workflow folder paths to include. At most 100 entries. A path covers its whole subtree. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", + "description": "Comma-separated workflow folder paths, including descendants. Up to 100 paths. Unknown folder paths contribute no matches.", "schema": { "type": "string", - "description": "Comma-separated workflow folder paths to include. At most 100 entries. A path covers its whole subtree. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error." + "description": "Comma-separated workflow folder paths, including descendants. Up to 100 paths. Unknown folder paths contribute no matches." } }, { @@ -505,10 +514,10 @@ "name": "segmentCount", "in": "query", "required": false, - "description": "Number of equal time buckets to divide the window into, from 1 to 500. Exactly this many buckets are always returned. Buckets are never narrower than one minute, so on a short window the series extends past the end of the window rather than being compressed, and the trailing buckets are empty.", + "description": "Number of time buckets, up to 500. Exactly this many are returned, each at least one minute wide. Short windows extend past the requested end and include empty trailing buckets.", "schema": { "default": 72, - "description": "Number of equal time buckets to divide the window into, from 1 to 500. Exactly this many buckets are always returned. Buckets are never narrower than one minute, so on a short window the series extends past the end of the window rather than being compressed, and the trailing buckets are empty.", + "description": "Number of time buckets, up to 500. Exactly this many are returned, each at least one minute wide. Short windows extend past the requested end and include empty trailing buckets.", "type": "integer", "minimum": 1, "maximum": 500 @@ -572,6 +581,12 @@ "in": "header", "name": "X-API-Key", "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description." + }, + "oauthBearer": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "OAuth 2.0 access token", + "description": "A Sim OAuth access token obtained by a registered client through the authorization-code flow. Each operation declares its required scope: api:read permits reads and searches; api:write also permits changes and execution and implies api:read. Scope requirements follow the application operation, independent of HTTP method or workspace role." } }, "headers": { @@ -643,7 +658,7 @@ } }, "Unauthorized": { - "description": "The API key is missing or invalid.", + "description": "The API credential is missing or invalid.", "content": { "application/json": { "schema": { @@ -652,7 +667,7 @@ "example": { "error": { "code": "UNAUTHORIZED", - "message": "API key required" + "message": "Authentication required" } } } @@ -772,6 +787,49 @@ } }, "schemas": { + "V2ActionableForbiddenDetails": { + "type": "object", + "properties": { + "code": { + "$ref": "#/components/schemas/V2ForbiddenDetailCode" + } + }, + "required": ["code"], + "additionalProperties": { + "description": "Additional context for this refusal." + }, + "title": "Actionable forbidden details", + "description": "Machine-readable cause and optional context for an actionable `403` response." + }, + "V2ForbiddenDetailCode": { + "type": "string", + "enum": [ + "INSUFFICIENT_WORKSPACE_ROLE", + "PERSONAL_API_KEYS_DISABLED", + "WORKSPACE_KEY_OPERATION_NOT_PERMITTED", + "PRINCIPAL_KIND_NOT_PERMITTED", + "ORGANIZATION_MEMBERSHIP_REQUIRED", + "ORGANIZATION_ADMIN_REQUIRED", + "ENTERPRISE_PLAN_REQUIRED", + "ORGANIZATION_PLAN_REQUIRED", + "AUDIT_LOGS_DISABLED", + "SKILL_EDITOR_ACCESS_REQUIRED", + "SECRET_ADMIN_ACCESS_REQUIRED", + "WORKSPACE_RESOURCE_LIMIT_REACHED", + "PUBLIC_SHARING_NOT_ALLOWED", + "CREDENTIAL_ADMIN_ACCESS_REQUIRED", + "MCP_SERVER_URL_NOT_ALLOWED", + "WORKSPACE_PLAN_CAPABILITY_REQUIRED", + "CHAT_AUTH_MODE_NOT_PERMITTED", + "CONNECTOR_MANAGED_RESOURCE_READ_ONLY", + "PERMISSION_GROUP_CAPABILITY_BLOCKED", + "INTEGRATION_NOT_ALLOWED", + "INSUFFICIENT_SCOPE", + "SCIM_MANAGED_MEMBERSHIP" + ], + "title": "Forbidden detail code", + "description": "Stable cause code for an actionable `403` response." + }, "V2Error": { "type": "object", "properties": { @@ -787,7 +845,15 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `ORGANIZATION_PLAN_REQUIRED` — The organization has no active organization subscription (Pro for Teams, Max for Teams, or Enterprise).\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\n- `WORKSPACE_PLAN_CAPABILITY_REQUIRED` — The workspace's plan does not include a capability this request depends on. The message names the capability; upgrading the workspace's plan is the remedy.\n- `CHAT_AUTH_MODE_NOT_PERMITTED` — The workspace's permission group does not allow the chat authentication mode the request selected. A mode already saved on the deployment may still be re-saved; changing to a disallowed one cannot.\n- `CONNECTOR_MANAGED_RESOURCE_READ_ONLY` — This resource is managed by a knowledge base connector and cannot be edited directly. Change it at the source and re-sync, or exclude the document from the connector.\n- `PERMISSION_GROUP_CAPABILITY_BLOCKED` — The caller's permission group does not allow this capability. The message names it; an organization admin controls the group.\n- `INTEGRATION_NOT_ALLOWED` — The integration this request names is outside the workspace's allowed set. An organization admin controls the permission group's integration allowlist, and a self-hosted deployment can narrow it further with ALLOWED_INTEGRATIONS." + "description": "Structured error context whose keys depend on the error. Actionable `403` responses use the `V2ActionableForbiddenDetails` shape; validation failures may return issue arrays instead.", + "anyOf": [ + { + "$ref": "#/components/schemas/V2ActionableForbiddenDetails" + }, + { + "description": "Other structured context defined by the specific error." + } + ] } }, "required": ["code", "message"], @@ -1043,11 +1109,11 @@ }, "duration": { "type": "number", - "description": "Legacy span duration in milliseconds." + "description": "Current trace-span duration in milliseconds." }, "durationMs": { "type": "number", - "description": "Span duration in milliseconds." + "description": "Compatibility field for span duration in milliseconds. Read `duration` for current trace spans." }, "startTime": { "type": "string", @@ -1483,7 +1549,7 @@ "type": "null" } ], - "description": "Workflow graph snapshot captured for the run, or null when none is retained. Credential-bearing values are redacted to null: `oauth-input`, `password: true`, table sub-block values, sensitive nested tool parameters, and any parameter without authoritative codec metadata. `{{VAR}}` references in non-opaque fields are preserved." + "description": "Workflow graph captured for the run, or null if unavailable. Sensitive values are redacted to null; environment-variable references may be preserved." }, "traceSpans": { "type": "array", @@ -1795,7 +1861,7 @@ }, "required": ["start", "end"], "additionalProperties": false, - "description": "The window the buckets span. `startDate` and `endDate` are used verbatim when supplied; an omitted edge falls back to the oldest matching run on the left and to the later of the newest matching run and now on the right. With no matching runs the right edge falls back to now and the left to 24 hours before that right edge — the trailing 24 hours when neither edge was supplied, and the 24 hours preceding `endDate` when only `endDate` was supplied. A supplied `startDate` is still used verbatim, so a `startDate` without an `endDate` yields `[startDate, now]`, which can be any width." + "description": "Actual bucket window. Supplied bounds are exact. Without `startDate`, the left edge is the oldest match, or 24 hours before the right edge when no run matches. Without `endDate`, the right edge is at least now. `startDate` alone spans through now." }, "segmentMs": { "type": "number", diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index c2ff564af75..533dcf0f5f3 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -23,7 +23,7 @@ "tags": [ { "name": "Meta", - "description": "Discover what the calling API key can reach." + "description": "Discover what the calling API credential can reach." }, { "name": "Workspaces", @@ -61,6 +61,9 @@ "security": [ { "apiKey": [] + }, + { + "oauthBearer": [] } ], "paths": { @@ -68,7 +71,9 @@ "get": { "operationId": "listWorkspaces", "summary": "List Workspaces", - "description": "List active workspaces available to the API key with opaque cursor pagination. A personal API key sees every accessible workspace that permits personal API keys; a workspace API key sees only its bound workspace.", + "description": "List active workspaces available to the calling credential with opaque cursor pagination. A personal API key or OAuth token sees accessible workspaces that permit user-held API credentials; a workspace API key sees only its bound workspace.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "workspaces.list_public", + "x-oauth-scope": "api:read", "tags": ["Workspaces"], "parameters": [ { @@ -122,7 +127,7 @@ ], "responses": { "200": { - "description": "Public metadata for workspaces available to the API key.", + "description": "Public metadata for workspaces available to the credential.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -170,7 +175,9 @@ "get": { "operationId": "getWorkspace", "summary": "Get Workspace", - "description": "Return public metadata for one accessible workspace. Governance identities, billing identities, and internal membership identifiers are intentionally omitted.", + "description": "Get metadata for an accessible workspace.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "workspaces.read_public_detail", + "x-oauth-scope": "api:read", "tags": ["Workspaces"], "parameters": [ { @@ -236,7 +243,9 @@ "get": { "operationId": "listWorkspaceMembers", "summary": "List Workspace Members", - "description": "List the workspace's effective members ordered by email. Explicit workspace grants and inherited organization-administrator grants are merged; internal membership and billing identities are omitted.", + "description": "List workspace members by email, including explicit grants and inherited organization admin access.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "workspaces.members.list_public", + "x-oauth-scope": "api:read", "tags": ["Workspaces"], "parameters": [ { @@ -326,7 +335,9 @@ "get": { "operationId": "listMcpServers", "summary": "List MCP Servers", - "description": "List MCP servers registered in a workspace. Request-header values and OAuth client secrets are never returned. The discovery fields stay at their registration defaults until `GET /api/v2/mcp-servers/{mcpServerId}/tools` runs a discovery.", + "description": "List MCP servers registered in a workspace, excluding request-header values and OAuth secrets. Connection metadata remains at registration defaults until List MCP Server Tools performs discovery.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "mcp_servers.list", + "x-oauth-scope": "api:read", "tags": ["MCP Servers"], "parameters": [ { @@ -450,7 +461,9 @@ "post": { "operationId": "createMcpServer", "summary": "Create MCP Server", - "description": "Register an MCP server in a workspace. The endpoint URL is the server identity, so a URL already registered here is a `409` — reconfigure that server with `PATCH /api/v2/mcp-servers/{mcpServerId}` instead. Registration never connects to the endpoint: the server comes back `disconnected` and stays unavailable until `GET /api/v2/mcp-servers/{mcpServerId}/tools` succeeds.", + "description": "Register an external MCP server without connecting to it. A duplicate URL returns `409`; use Update MCP Server to change the existing registration. The server remains disconnected until List MCP Server Tools succeeds.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "mcp_servers.create", + "x-oauth-scope": "api:write", "tags": ["MCP Servers"], "requestBody": { "required": true, @@ -522,7 +535,9 @@ "get": { "operationId": "getMcpServer", "summary": "Get MCP Server", - "description": "Fetch one MCP server by identifier. Request-header values and OAuth client secrets are never returned.", + "description": "Get one MCP server by identifier. Request-header values and OAuth client secrets are never returned.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "mcp_servers.read", + "x-oauth-scope": "api:read", "tags": ["MCP Servers"], "parameters": [ { @@ -597,7 +612,9 @@ "patch": { "operationId": "updateMcpServer", "summary": "Update MCP Server", - "description": "Update the supplied MCP server fields. Omitted fields are retained, except where a field says otherwise. Any change that invalidates authentication revokes the stored OAuth grant, resets `connectionStatus` to `disconnected`, and clears `lastConnected` and `lastError`, so the server must be rediscovered.", + "description": "Update an MCP server's supplied fields. Omitted fields remain unchanged unless the field specifies otherwise. Authentication changes revoke the stored OAuth grant and reset connection metadata. Use List MCP Server Tools to reconnect.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "mcp_servers.update", + "x-oauth-scope": "api:write", "tags": ["MCP Servers"], "parameters": [ { @@ -677,7 +694,9 @@ "delete": { "operationId": "deleteMcpServer", "summary": "Delete MCP Server", - "description": "Remove an MCP server and revoke its OAuth tokens. Workflows retain blocks that referenced the server's tools, but those tools can no longer be called.", + "description": "Remove an MCP server and revoke its OAuth tokens. Workflows retain blocks that referenced the server's tools, but those tools can no longer be called.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "mcp_servers.delete", + "x-oauth-scope": "api:write", "tags": ["MCP Servers"], "parameters": [ { @@ -754,7 +773,9 @@ "get": { "operationId": "listMcpServerTools", "summary": "List MCP Server Tools", - "description": "Connect to a registered MCP server and return the tools it exposes. This read has side effects: it opens a live connection to the third-party server and writes `connectionStatus`, `toolCount`, `lastError`, and `lastToolsRefresh`. A `HEAD` skips the effect but is authorized exactly as the `GET` is, so it answers `400`, `401`, `403`, or `404` wherever the `GET` would and an empty `200` otherwise. Skipping the effect means skipping the read that produces the payload, so that `200` carries none of the response headers documented below — it answers whether the `GET` would be allowed, not what the `GET` would return. Discovery is bounded at 1,000 tools and 5 MB of tool payload per server. The bounded set is returned in one page; `nextCursor` is always null. An unreachable, slow, or cooling-down server is a `503`; a stored OAuth grant that no longer works is a `409` with `error.details.code` `MCP_SERVER_REAUTHORIZATION_REQUIRED`, which only a human reauthorizing in Sim can clear. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Discover up to 1,000 tools within 5 MB, connect to the server, and update connection metadata. Results are unpaginated. Invalid OAuth returns `409` with `MCP_SERVER_REAUTHORIZATION_REQUIRED`; reauthorize through the browser. Unavailable servers return `503`. `HEAD` checks access with the same authorization as `GET` but skips side effects, returning an empty `200` without payload headers on success. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "mcp_servers.tools.discover", + "x-oauth-scope": "api:write", "tags": ["MCP Servers"], "parameters": [ { @@ -784,9 +805,9 @@ "name": "refresh", "in": "query", "required": false, - "description": "Bypass the short-lived per-workspace tool cache and reconnect under your own credentials. A cached result reflects whichever workspace member last ran discovery, so this is the only way to pick up a tool added since then; it costs a live round trip.", + "description": "Refresh tools using your credentials. Otherwise results may reuse another workspace member's recent discovery and omit newly added tools.", "schema": { - "description": "Bypass the short-lived per-workspace tool cache and reconnect under your own credentials. A cached result reflects whichever workspace member last ran discovery, so this is the only way to pick up a tool added since then; it costs a live round trip.", + "description": "Refresh tools using your credentials. Otherwise results may reuse another workspace member's recent discovery and omit newly added tools.", "type": "boolean" } } @@ -844,7 +865,9 @@ "get": { "operationId": "listSkills", "summary": "List Skills", - "description": "List workspace and built-in skills with opaque cursor pagination. Built-ins are marked read-only. The list omits skill bodies; fetch one skill to read its content.", + "description": "List workspace and built-in skills with cursor pagination. Built-in skills are read-only. The list omits skill bodies; use Get Skill to read content.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "skills.list", + "x-oauth-scope": "api:read", "tags": ["Skills"], "parameters": [ { @@ -968,7 +991,9 @@ "post": { "operationId": "createSkill", "summary": "Create Skill", - "description": "Create one skill in a workspace. Its kebab-case name must be unique and cannot be reserved by a built-in skill. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Create one skill in a workspace. Its kebab-case name must be unique and cannot be reserved by a built-in skill. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "skills.create", + "x-oauth-scope": "api:write", "tags": ["Skills"], "requestBody": { "required": true, @@ -1040,7 +1065,9 @@ "get": { "operationId": "getSkill", "summary": "Get Skill", - "description": "Fetch one workspace or built-in skill, including its full content. Built-in skills are marked read-only.", + "description": "Get one workspace or built-in skill, including its full content. Built-in skills are marked read-only.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "skills.read", + "x-oauth-scope": "api:read", "tags": ["Skills"], "parameters": [ { @@ -1115,7 +1142,9 @@ "patch": { "operationId": "updateSkill", "summary": "Update Skill", - "description": "Update the supplied fields on a workspace skill. Omitted fields retain their stored values. Built-in skills are read-only. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Update a workspace skill. Omitted fields remain unchanged. Built-in skills are read-only. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "skills.update", + "x-oauth-scope": "api:write", "tags": ["Skills"], "parameters": [ { @@ -1198,7 +1227,9 @@ "delete": { "operationId": "deleteSkill", "summary": "Delete Skill", - "description": "Delete a workspace skill. Built-in skills are read-only and cannot be deleted. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Delete a workspace skill. Built-in skills are read-only and cannot be deleted. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "skills.delete", + "x-oauth-scope": "api:write", "tags": ["Skills"], "parameters": [ { @@ -1275,7 +1306,9 @@ "get": { "operationId": "listSkillEditors", "summary": "List Skill Editors", - "description": "List explicit skill editors and workspace administrators with opaque cursor pagination. Internal user and membership identifiers are never returned.", + "description": "List skill editors and workspace administrators with cursor pagination.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "skills.editors.list", + "x-oauth-scope": "api:read", "tags": ["Skills"], "parameters": [ { @@ -1398,7 +1431,9 @@ "post": { "operationId": "grantSkillEditor", "summary": "Grant Skill Editor", - "description": "Grant editor access to a current workspace member by email. The caller must already be a skill editor or workspace administrator. Workspace administrators already have derived editor access and cannot receive an explicit grant. A retried existing grant returns 200; a newly created grant returns 201. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Grant skill editor access to a workspace member by email. Requires an existing editor or workspace admin; admins already have access and cannot receive explicit grants. Existing grants return `200`; new grants return `201`. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "skills.editors.grant", + "x-oauth-scope": "api:write", "tags": ["Skills"], "parameters": [ { @@ -1499,7 +1534,9 @@ "delete": { "operationId": "revokeSkillEditor", "summary": "Revoke Skill Editor", - "description": "Revoke an explicit editor grant by email. The caller must already be a skill editor or workspace administrator. Workspace administrators have derived access that cannot be revoked. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Revoke an explicit editor grant by email. The caller must already be a skill editor or workspace administrator. Workspace administrators have derived access that cannot be revoked. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "skills.editors.revoke", + "x-oauth-scope": "api:write", "tags": ["Skills"], "parameters": [ { @@ -1588,7 +1625,9 @@ "get": { "operationId": "listCustomTools", "summary": "List Custom Tools", - "description": "List code-backed custom tools defined in a workspace, with opaque cursor pagination. Legacy personal tools are excluded.", + "description": "List code-backed custom tools in a workspace with cursor pagination.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "custom_tools.list", + "x-oauth-scope": "api:read", "tags": ["Custom Tools"], "parameters": [ { @@ -1712,7 +1751,9 @@ "post": { "operationId": "createCustomTool", "summary": "Create Custom Tool", - "description": "Create a code-backed custom tool in a workspace. Its title must be unique because tools resolve by title at call time.", + "description": "Create a code-backed custom tool in a workspace. Its title must be unique because tools resolve by title at call time.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "custom_tools.create", + "x-oauth-scope": "api:write", "tags": ["Custom Tools"], "requestBody": { "required": true, @@ -1784,7 +1825,9 @@ "get": { "operationId": "getCustomTool", "summary": "Get Custom Tool", - "description": "Fetch one custom tool by identifier, scoped to its workspace.", + "description": "Get one custom tool by identifier, scoped to its workspace.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "custom_tools.read", + "x-oauth-scope": "api:read", "tags": ["Custom Tools"], "parameters": [ { @@ -1859,7 +1902,9 @@ "patch": { "operationId": "updateCustomTool", "summary": "Update Custom Tool", - "description": "Update the supplied custom tool fields. Omitted fields retain their stored values, and titles must remain unique within the workspace.", + "description": "Update a custom tool. Omitted fields remain unchanged; titles must remain unique within the workspace.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "custom_tools.update", + "x-oauth-scope": "api:write", "tags": ["Custom Tools"], "parameters": [ { @@ -1942,7 +1987,9 @@ "delete": { "operationId": "deleteCustomTool", "summary": "Delete Custom Tool", - "description": "Delete a custom tool. Agent blocks retain their configuration but can no longer call the deleted tool.", + "description": "Delete a custom tool. Agent blocks retain their configuration but can no longer call the deleted tool.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "custom_tools.delete", + "x-oauth-scope": "api:write", "tags": ["Custom Tools"], "parameters": [ { @@ -2019,7 +2066,9 @@ "get": { "operationId": "listSandboxes", "summary": "List Sandboxes", - "description": "List the sandboxes defined in a workspace, with opaque cursor pagination. A sandbox is a reusable dependency set — npm or PyPI packages, pinned managed CLIs, and Debian packages — that Function blocks execute against. Listing is not plan-gated, so a workspace that dropped below the Max tier still sees what it built.", + "description": "List reusable dependency environments for Function blocks, including language packages, managed CLIs, and system packages. Sandboxes remain visible after a plan downgrade.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "sandboxes.list", + "x-oauth-scope": "api:read", "tags": ["Sandboxes"], "parameters": [ { @@ -2143,7 +2192,9 @@ "post": { "operationId": "createSandbox", "summary": "Create Sandbox", - "description": "Create a sandbox. The name must be unique within the workspace. Where the deployment prebuilds dependency images, the build is scheduled and reported through `buildStatus`; a deployment that installs at run time, or a sandbox with nothing to install, has no build and reports `buildStatus: null`. A dependency or system-package entry the builder cannot accept is a `400` whose `error.details` names the field and the offending entries. Requires a workspace admin on a Max or Enterprise plan; a lower plan is refused with `403` and `error.details.code` `WORKSPACE_PLAN_CAPABILITY_REQUIRED`. Creates and updates in a workspace share one write budget, whatever the install strategy, and a burst is refused with `429` and a `Retry-After` header. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Create a uniquely named dependency environment. If a build is needed, track readiness with `buildStatus`; null means no build is required. Invalid dependencies return `400` with field details. Requires workspace admin access on Max or Enterprise. Creates and updates share a rate limit; respect `Retry-After`. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "sandboxes.create", + "x-oauth-scope": "api:write", "tags": ["Sandboxes"], "requestBody": { "required": true, @@ -2215,7 +2266,9 @@ "get": { "operationId": "getSandbox", "summary": "Get Sandbox", - "description": "Fetch one sandbox by identifier, scoped to its workspace, including its current build state and any build failure.", + "description": "Get one sandbox by identifier, scoped to its workspace, including its current build state and any build failure.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "sandboxes.read", + "x-oauth-scope": "api:read", "tags": ["Sandboxes"], "parameters": [ { @@ -2290,7 +2343,9 @@ "patch": { "operationId": "updateSandbox", "summary": "Update Sandbox", - "description": "Update the supplied sandbox fields. Omitted fields retain their stored values; a supplied list replaces the whole list; names must remain unique within the workspace. Where the deployment prebuilds dependency images, a changed spec is rebuilt and re-sending an unchanged spec after a failed build retries it; a deployment that installs at run time, or a spec with nothing to install, has no build and reports `buildStatus: null`. Requires a workspace admin on a Max or Enterprise plan; a lower plan is refused with `403` and `error.details.code` `WORKSPACE_PLAN_CAPABILITY_REQUIRED`. Creates and updates in a workspace share one write budget, whatever the install strategy, and a burst is refused with `429` and a `Retry-After` header. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Update a sandbox, preserving omitted fields and replacing supplied lists. Dependency changes may start a build; resending a failed specification retries its build. `buildStatus: null` means no build is required. Requires workspace admin access on Max or Enterprise. Creates and updates share a rate limit; respect `Retry-After`. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "sandboxes.update", + "x-oauth-scope": "api:write", "tags": ["Sandboxes"], "parameters": [ { @@ -2373,7 +2428,9 @@ "delete": { "operationId": "deleteSandbox", "summary": "Delete Sandbox", - "description": "Delete a sandbox. Function blocks that still select it fail closed at run time until they are re-pointed. Where the deployment prebuilds dependency images, the sandbox's image is released once nothing else shares it; a runtime-install deployment, or a spec with nothing to install, had no image and nothing is released. Requires a workspace admin on a Max or Enterprise plan; a lower plan is refused with `403` and `error.details.code` `WORKSPACE_PLAN_CAPABILITY_REQUIRED`. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Delete a sandbox. Function blocks using it fail until reconfigured. Requires workspace admin access on Max or Enterprise. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "sandboxes.delete", + "x-oauth-scope": "api:write", "tags": ["Sandboxes"], "parameters": [ { @@ -2450,7 +2507,9 @@ "get": { "operationId": "listCredentials", "summary": "List Credentials", - "description": "List OAuth and service-account connections visible to the caller. Secret material is never returned.", + "description": "List OAuth and service-account connections visible to the caller. Secret material is never returned.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "credentials.connections.list", + "x-oauth-scope": "api:read", "tags": ["Credentials"], "parameters": [ { @@ -2596,7 +2655,9 @@ "post": { "operationId": "createServiceAccountCredential", "summary": "Create Service-Account Credential", - "description": "Verify and store one service-account credential. Use provider discovery to select a service-account provider, then encode its required fields as the JSON object string in credentials. The credentials string is write-only and is never returned. A retried source match returns the existing credential with 200; a newly created credential returns 201. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Verify and store a service-account credential using the fields from List Credential Providers, encoded as a JSON object string in `credentials`. Secrets are never returned. A matching source returns the existing credential with `200`; creation returns `201`. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "credentials.service_accounts.create", + "x-oauth-scope": "api:write", "tags": ["Credentials"], "requestBody": { "required": true, @@ -2689,7 +2750,9 @@ "get": { "operationId": "listCredentialProviders", "summary": "List Credential Providers", - "description": "List catalogued OAuth and service-account connection methods and whether each is available to the caller in this workspace and deployment. Optionally search provider names with a case-insensitive substring match. OAuth authorization options contain the exact provider IDs accepted by the browser connection endpoint; service-account methods list the exact create-body fields and mark secret fields write-only. The bounded set is returned in one page; `nextCursor` is always null.", + "description": "List OAuth and service-account connection methods and their availability. OAuth options provide provider IDs for browser connections; service-account methods declare required fields and write-only secrets. Supports provider-name search. Returns the complete set in one page; `nextCursor` is always null.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "credentials.providers.list", + "x-oauth-scope": "api:read", "tags": ["Credentials"], "parameters": [ { @@ -2767,7 +2830,9 @@ "post": { "operationId": "createCredentialConnection", "summary": "Create Credential Connection", - "description": "Create a short-lived browser URL for connecting an OAuth provider or reconnecting an existing OAuth credential. Open the URL in a browser, sign in as the personal API-key owner, complete provider authorization, then refresh the credentials list. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Create a short-lived browser URL for connecting an OAuth provider or reconnecting an existing OAuth credential. Open the URL, sign in as the authenticated user, complete provider authorization, then refresh the credentials list. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "credentials.connections.create", + "x-oauth-scope": "api:write", "tags": ["Credentials"], "requestBody": { "required": true, @@ -2839,7 +2904,9 @@ "delete": { "operationId": "deleteCredential", "summary": "Disconnect Credential", - "description": "Disconnect an OAuth or service-account credential and clear its stored workflow, deployment, paused-run, knowledge-connector, and webhook references. Credential admin access is required. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Disconnect an OAuth or service-account credential and clear its stored workflow, deployment, paused-run, knowledge-connector, and webhook references. Credential admin access is required. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "credentials.delete", + "x-oauth-scope": "api:write", "tags": ["Credentials"], "parameters": [ { @@ -2915,7 +2982,9 @@ "patch": { "operationId": "updateCredential", "summary": "Update Credential", - "description": "Rotate a service-account credential's secret material, or rename it. Send only the fields to change: an omitted field is left unchanged, and `description: null` clears the stored description. Secret fields are write-only and are never returned, and only a service-account credential has any: sending one for a credential of another type answers `400` rather than dropping it. The provider re-verifies replacement secret material before it replaces the stored secret, so a rejected secret leaves the stored one untouched and answers `400` with the provider's code in `error.details.providerErrorCode`; a provider that cannot be reached answers `503`. The credential ID is preserved, so every workflow, deployment, paused run, knowledge connector, and webhook that references it keeps working — which disconnecting and re-creating does not. Credential admin access is required. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Rename a service-account credential or rotate its secret fields, preserving omitted values and the credential ID. Requires credential admin access. Provider rejection preserves the old secret and returns `400` with `providerErrorCode`; outages return `503`. Fields for a different credential type return `400`. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "credentials.update", + "x-oauth-scope": "api:write", "tags": ["Credentials"], "parameters": [ { @@ -3013,7 +3082,9 @@ "get": { "operationId": "listSecrets", "summary": "List Secrets", - "description": "List workspace and caller-owned personal secret metadata with opaque cursor pagination. Rows for workspace secrets marked visible (unredacted) include the stored value; every other row is metadata-only and no other response ever carries a value. A workspace API key is rejected with `403`; use a personal API key.", + "description": "List workspace and caller-owned personal secrets with cursor pagination. Only workspace secrets marked `unredacted` include values; all other entries contain metadata only. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "secrets.list", + "x-oauth-scope": "api:read", "tags": ["Secrets"], "parameters": [ { @@ -3150,7 +3221,9 @@ "put": { "operationId": "setSecret", "summary": "Set Secret", - "description": "Create or replace a workspace or caller-owned personal secret. The value is encrypted at rest, is write-only, and is never included in the response. Omit `value` on a workspace secret to update `description` and `unredacted` alone: the stored value is left untouched and is never re-encrypted, and because a metadata-only write cannot create a secret it answers `404` when the named secret does not exist. A personal secret always requires `value`, having no other writable field. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Create or replace a workspace or personal secret without returning its value. For existing workspace secrets, omit `value` to update metadata only; this returns `404` if absent. Personal secrets always require `value`. List Secrets can reveal workspace values marked `unredacted`. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "secrets.set", + "x-oauth-scope": "api:write", "tags": ["Secrets"], "parameters": [ { @@ -3253,7 +3326,9 @@ "delete": { "operationId": "deleteSecret", "summary": "Delete Secret", - "description": "Delete a workspace or caller-owned personal secret without reading or returning its stored value. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Delete a workspace or caller-owned personal secret without reading or returning its stored value. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "secrets.delete", + "x-oauth-scope": "api:write", "tags": ["Secrets"], "parameters": [ { @@ -3343,11 +3418,13 @@ "get": { "operationId": "getApiMeta", "summary": "Get API Capabilities", - "description": "Report whether v2 is available, whether the calling API key is personal or workspace-scoped, and when it expires. Requires a valid key.", + "description": "Get whether v2 is available, what kind of API credential is calling, and when it expires. Requires a valid API key or OAuth access token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "meta.capabilities.read", + "x-oauth-scope": "api:read", "tags": ["Meta"], "responses": { "200": { - "description": "Availability and lifecycle facts about the calling key.", + "description": "Availability and lifecycle facts about the calling credential.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -3389,7 +3466,9 @@ "get": { "operationId": "listWorkflowMcpServers", "summary": "List Workflow MCP Servers", - "description": "List the MCP servers a workspace *publishes*. These serve deployed workflows as tools to outside MCP clients, which is the opposite direction from `GET /api/v2/mcp-servers` — that lists external servers Sim calls. Each entry carries the endpoint clients connect to and the tool names it exposes; those names are gathered under a 2,000-tool budget shared across the page, so on a page of unusually large servers the trailing entries can list fewer names than they publish. Read one server's full inventory with `GET /api/v2/workflow-mcp-servers/{serverId}/tools`. A workspace API key is rejected with `403`; use a personal API key.", + "description": "List MCP servers that expose deployed workflows to external clients. Use List MCP Servers for external servers Sim calls. Tool names share a 2,000-name page limit; inspect `toolNamesTruncated` and use List Workflow MCP Tools for a server's inventory. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "mcp_servers.workflow_deployments.list", + "x-oauth-scope": "api:read", "tags": ["MCP Servers"], "parameters": [ { @@ -3501,7 +3580,9 @@ "post": { "operationId": "createWorkflowMcpServer", "summary": "Create Workflow MCP Server", - "description": "Publish a new MCP server for a workspace, optionally seeding it with workflows to expose as tools. Every workflow named in `workflowIds` must already be deployed. Setting `isPublic` lets any MCP client holding the server URL execute the workflows it publishes without a Sim API key. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Create an MCP server that exposes deployed workflows as tools. Every supplied workflow must already be deployed. With `isPublic: true`, anyone with the server URL can execute its workflows without a Sim API key. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "mcp_servers.workflow_deployments.create_server", + "x-oauth-scope": "api:write", "tags": ["MCP Servers"], "requestBody": { "required": true, @@ -3573,7 +3654,9 @@ "get": { "operationId": "getWorkflowMcpServer", "summary": "Get Workflow MCP Server", - "description": "Read one published MCP server. The list is the only other place this state is published, so a caller holding a server id would otherwise have to page the collection and filter client-side. The tools it publishes are on its `tools` sub-resource. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Get a published workflow MCP server's metadata and client endpoint. Use List Workflow MCP Tools for its tool inventory. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "mcp_servers.workflow_deployments.read_server", + "x-oauth-scope": "api:read", "tags": ["MCP Servers"], "parameters": [ { @@ -3636,7 +3719,9 @@ "patch": { "operationId": "updateWorkflowMcpServer", "summary": "Update Workflow MCP Server", - "description": "Rename, re-describe, or change the public visibility of a published MCP server. Merge-patch shaped: an omitted key is unchanged and `description: null` clears the description. Publishing and unpublishing the workflows it serves are separate operations on its `tools` sub-resource. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Update a workflow MCP server's name, description, or public access. Omitted fields remain unchanged; `description: null` clears the description. Publish or unpublish tools separately. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "mcp_servers.workflow_deployments.update_server", + "x-oauth-scope": "api:write", "tags": ["MCP Servers"], "parameters": [ { @@ -3719,7 +3804,9 @@ "delete": { "operationId": "deleteWorkflowMcpServer", "summary": "Delete Workflow MCP Server", - "description": "Unpublish an MCP server. Every tool it served stops answering and connected clients lose the endpoint. The workflows themselves are untouched — their own deployments stay live and executable through the workflow API. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Delete a workflow MCP server and stop serving its tools. The underlying workflows remain deployed and executable through the workflow API. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "mcp_servers.workflow_deployments.delete_server", + "x-oauth-scope": "api:write", "tags": ["MCP Servers"], "parameters": [ { @@ -3787,7 +3874,9 @@ "get": { "operationId": "listWorkflowMcpTools", "summary": "List Workflow MCP Tools", - "description": "Every tool a server publishes, tool-name ordered. The server list reports tool *names* only, so this is where a caller reads the `workflowId` that `DELETE /api/v2/workflow-mcp-servers/{serverId}/tools/{workflowId}` addresses. Returned in one page rather than paged — so `nextCursor` is always null — and capped at 2,000 tools, which is far above any real server's inventory. A workspace API key is rejected with `403`; use a personal API key.", + "description": "List a server's published tools by name, including workflow IDs used to unpublish them. Returns up to 2,000 tools with `nextCursor: null`; `truncated` indicates an incomplete inventory that cannot be paginated. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "mcp_servers.workflow_deployments.list_tools", + "x-oauth-scope": "api:read", "tags": ["MCP Servers"], "parameters": [ { @@ -3850,7 +3939,9 @@ "post": { "operationId": "deployWorkflowMcpTool", "summary": "Publish Workflow As MCP Tool", - "description": "Publish a deployed workflow as a tool on an MCP server. The tool's input schema is generated from the deployed workflow's input format, so the workflow must already be deployed. Idempotent per workflow: a server carries at most one tool per workflow, so a repeat call replaces the existing tool and answers `200` with `updated: true` rather than conflicting. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Publish a deployed workflow as an MCP tool using its deployed input schema. Each server has at most one tool per workflow; repeating the call replaces that tool and returns `200` with `updated: true`. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "mcp_servers.workflow_deployments.deploy_tool", + "x-oauth-scope": "api:write", "tags": ["MCP Servers"], "parameters": [ { @@ -3935,7 +4026,9 @@ "delete": { "operationId": "undeployWorkflowMcpTool", "summary": "Unpublish Workflow MCP Tool", - "description": "Remove a workflow from an MCP server. Addressed by workflow rather than by tool identifier, because a server carries at most one live tool per workflow. The workflow's own deployment is untouched. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Unpublish an MCP tool by its workflow ID. The workflow's API deployment remains active. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "mcp_servers.workflow_deployments.undeploy_tool", + "x-oauth-scope": "api:write", "tags": ["MCP Servers"], "parameters": [ { @@ -4014,7 +4107,9 @@ "get": { "operationId": "listBlocks", "summary": "List Blocks", - "description": "List the blocks available in a workspace, built-in and workspace-deployed alike, discriminated by `source`. Availability is caller-specific: the workspace’s integration allowlist, the organization’s revealed preview blocks, and the deployment’s allowlist all narrow the result. Use `capability=trigger` for the blocks that can start a workflow. Summaries name their tools and operations by id — resolve one with Get Block or Get Tool.", + "description": "List built-in and workspace-deployed blocks visible to the caller. Integration allowlists and preview visibility restrict results. Use `capability=trigger` for workflow starters and Get Block or Get Tool to resolve operation and tool IDs.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "catalog.blocks.list", + "x-oauth-scope": "api:read", "tags": ["Catalog"], "parameters": [ { @@ -4067,9 +4162,9 @@ "name": "source", "in": "query", "required": false, - "description": "Restrict to shipped blocks or to this workspace’s deployed custom blocks.", + "description": "Restrict to built-in blocks or this workspace's deployed custom blocks.", "schema": { - "description": "Restrict to shipped blocks or to this workspace’s deployed custom blocks.", + "description": "Restrict to built-in blocks or this workspace's deployed custom blocks.", "type": "string", "enum": ["builtin", "custom"] } @@ -4173,7 +4268,9 @@ "get": { "operationId": "getBlock", "summary": "Get Block", - "description": "Read one block’s full configuration shape: its fields and their conditions, its operations with the tool each runs, every tool’s parameters and outputs, and its triggers. An unversioned base type resolves to the newest version this caller can see — `confluence` answers with `confluence_v2` — and the returned `id` is always the resolved one, matching Get Tool. A block this caller cannot see answers 404, identically to one that does not exist.", + "description": "Get a block's fields, conditions, operations, tool schemas, and triggers. Unversioned types resolve to the newest visible version; the returned `id` identifies that version. Hidden or missing blocks return `404`.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "catalog.blocks.read", + "x-oauth-scope": "api:read", "tags": ["Catalog"], "parameters": [ { @@ -4251,7 +4348,9 @@ "get": { "operationId": "listTools", "summary": "List Tools", - "description": "List the built-in tools available in a workspace. Built-in tools only: a workspace’s MCP tools are discovered per server on List MCP Server Tools, and its code-backed custom tools are on List Custom Tools. A tool is available when a block the caller can see exposes it, so the same allowlist and visibility rules as List Blocks apply.", + "description": "List built-in tools exposed by blocks visible to the caller. Use List MCP Server Tools for an external server's tools and List Custom Tools for workspace code-backed tools.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "catalog.tools.list", + "x-oauth-scope": "api:read", "tags": ["Catalog"], "parameters": [ { @@ -4400,7 +4499,9 @@ "get": { "operationId": "getTool", "summary": "Get Tool", - "description": "Read one built-in tool’s declared parameters and outputs. A name that is itself a registered id answers as that exact tool; a name that is not resolves to the newest version of its family. The returned `id` is always the one that answered, so a caller can see which version it got. A tool the workspace’s visible blocks do not expose answers `404`, identically to one that does not exist.", + "description": "Get a built-in tool's parameters and outputs. Registered IDs resolve exactly; other names resolve to the newest family version. The returned `id` identifies the resolved tool. Hidden or missing tools return `404`.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "catalog.tools.read", + "x-oauth-scope": "api:read", "tags": ["Catalog"], "parameters": [ { @@ -4478,7 +4579,9 @@ "post": { "operationId": "executeTool", "summary": "Run Tool", - "description": "Run one built-in tool and return what it produced. Supply `input` using the parameter ids `GET /api/v2/tools/{toolId}` publishes; Sim resolves the credential named by `credentialId`, injects a hosted API key for the tools it supplies one for, and substitutes environment-variable references, so the request carries arguments rather than secrets. A parameter the tool marks `user-only` also accepts `{{VAR_NAME}}` as its whole value, resolved server-side against the workspace environment; every other value is sent verbatim, so a literal secret passes through untouched. A tool that runs and refuses is a `200` carrying `status: \"failed\"` and the reason — the error envelope is reserved for failures of this API, not of the third party. A tool the workspace's visible blocks do not expose answers `404` identically to one that does not exist; one whose integration the workspace does not permit answers `403` with `error.details.code` `INTEGRATION_NOT_ALLOWED`. Hosted-key spend this call incurs is billed to the workspace. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Run a built-in tool using published parameter IDs. Sim resolves `credentialId`, hosted keys, and whole-value `{{VAR_NAME}}` references for `user-only` parameters; other values pass through verbatim. Third-party refusal returns `200` with `status: \"failed\"`; the error envelope covers API failures. Hidden or missing tools return `404`; disallowed integrations return `403` with `error.details.code: INTEGRATION_NOT_ALLOWED`. Hosted-key use is billed to the workspace. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "tools.execute", + "x-oauth-scope": "api:write", "tags": ["Catalog"], "parameters": [ { @@ -4561,7 +4664,9 @@ "get": { "operationId": "listConnectorTypes", "summary": "List Connector Types", - "description": "List every knowledge-base connector type and the source configuration each accepts. Two properties of a config field decide how its value is sent and are not inferable from the rest: a field with `multi: true` stores a `string[]` rather than a `string`, and a `canonicalParamId` links a picker field to a manual-entry field that write the SAME configuration key — send exactly one of the pair, keyed by `canonicalParamId` rather than by the field's own `id`. The bounded set is returned in one page; `nextCursor` is always null.", + "description": "List connector types and accepted source configuration. A field with `multi: true` stores `string[]`. `canonicalParamId` links picker and manual fields that write the same key; send exactly one, keyed by `canonicalParamId` rather than its own `id`. Returns the complete set in one page; `nextCursor` is always null.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "catalog.connector_types.list", + "x-oauth-scope": "api:read", "tags": ["Catalog"], "parameters": [ { @@ -4643,6 +4748,12 @@ "in": "header", "name": "X-API-Key", "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description." + }, + "oauthBearer": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "OAuth 2.0 access token", + "description": "A Sim OAuth access token obtained by a registered client through the authorization-code flow. Each operation declares its required scope: api:read permits reads and searches; api:write also permits changes and execution and implies api:read. Scope requirements follow the application operation, independent of HTTP method or workspace role." } }, "headers": { @@ -4714,7 +4825,7 @@ } }, "Unauthorized": { - "description": "The API key is missing or invalid.", + "description": "The API credential is missing or invalid.", "content": { "application/json": { "schema": { @@ -4723,7 +4834,7 @@ "example": { "error": { "code": "UNAUTHORIZED", - "message": "API key required" + "message": "Authentication required" } } } @@ -4875,6 +4986,49 @@ } }, "schemas": { + "V2ActionableForbiddenDetails": { + "type": "object", + "properties": { + "code": { + "$ref": "#/components/schemas/V2ForbiddenDetailCode" + } + }, + "required": ["code"], + "additionalProperties": { + "description": "Additional context for this refusal." + }, + "title": "Actionable forbidden details", + "description": "Machine-readable cause and optional context for an actionable `403` response." + }, + "V2ForbiddenDetailCode": { + "type": "string", + "enum": [ + "INSUFFICIENT_WORKSPACE_ROLE", + "PERSONAL_API_KEYS_DISABLED", + "WORKSPACE_KEY_OPERATION_NOT_PERMITTED", + "PRINCIPAL_KIND_NOT_PERMITTED", + "ORGANIZATION_MEMBERSHIP_REQUIRED", + "ORGANIZATION_ADMIN_REQUIRED", + "ENTERPRISE_PLAN_REQUIRED", + "ORGANIZATION_PLAN_REQUIRED", + "AUDIT_LOGS_DISABLED", + "SKILL_EDITOR_ACCESS_REQUIRED", + "SECRET_ADMIN_ACCESS_REQUIRED", + "WORKSPACE_RESOURCE_LIMIT_REACHED", + "PUBLIC_SHARING_NOT_ALLOWED", + "CREDENTIAL_ADMIN_ACCESS_REQUIRED", + "MCP_SERVER_URL_NOT_ALLOWED", + "WORKSPACE_PLAN_CAPABILITY_REQUIRED", + "CHAT_AUTH_MODE_NOT_PERMITTED", + "CONNECTOR_MANAGED_RESOURCE_READ_ONLY", + "PERMISSION_GROUP_CAPABILITY_BLOCKED", + "INTEGRATION_NOT_ALLOWED", + "INSUFFICIENT_SCOPE", + "SCIM_MANAGED_MEMBERSHIP" + ], + "title": "Forbidden detail code", + "description": "Stable cause code for an actionable `403` response." + }, "V2Error": { "type": "object", "properties": { @@ -4890,7 +5044,15 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `ORGANIZATION_PLAN_REQUIRED` — The organization has no active organization subscription (Pro for Teams, Max for Teams, or Enterprise).\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\n- `WORKSPACE_PLAN_CAPABILITY_REQUIRED` — The workspace's plan does not include a capability this request depends on. The message names the capability; upgrading the workspace's plan is the remedy.\n- `CHAT_AUTH_MODE_NOT_PERMITTED` — The workspace's permission group does not allow the chat authentication mode the request selected. A mode already saved on the deployment may still be re-saved; changing to a disallowed one cannot.\n- `CONNECTOR_MANAGED_RESOURCE_READ_ONLY` — This resource is managed by a knowledge base connector and cannot be edited directly. Change it at the source and re-sync, or exclude the document from the connector.\n- `PERMISSION_GROUP_CAPABILITY_BLOCKED` — The caller's permission group does not allow this capability. The message names it; an organization admin controls the group.\n- `INTEGRATION_NOT_ALLOWED` — The integration this request names is outside the workspace's allowed set. An organization admin controls the permission group's integration allowlist, and a self-hosted deployment can narrow it further with ALLOWED_INTEGRATIONS." + "description": "Structured error context whose keys depend on the error. Actionable `403` responses use the `V2ActionableForbiddenDetails` shape; validation failures may return issue arrays instead.", + "anyOf": [ + { + "$ref": "#/components/schemas/V2ActionableForbiddenDetails" + }, + { + "description": "Other structured context defined by the specific error." + } + ] } }, "required": ["code", "message"], @@ -4988,7 +5150,7 @@ "required": ["data", "nextCursor"], "additionalProperties": false, "title": "List workspaces response", - "description": "Public metadata for workspaces available to the API key.", + "description": "Public metadata for workspaces available to the credential.", "examples": [ { "data": [ @@ -5353,7 +5515,7 @@ "maxLength": 2000 }, "transport": { - "description": "Transport used to communicate with the server. Applied server-side as `streamable-http` when omitted on create.", + "description": "Transport protocol. Defaults to `streamable-http` on creation.", "default": "streamable-http", "type": "string", "enum": ["streamable-http"] @@ -5383,21 +5545,21 @@ } }, "timeout": { - "description": "Per-request timeout in milliseconds. Applied server-side as 30000 when omitted on create.", + "description": "Per-request timeout in milliseconds. Defaults to 30000 on creation.", "default": 30000, "type": "integer", "minimum": 1000, "maximum": 300000 }, "retries": { - "description": "Number of retries per request. Applied server-side as 3 when omitted on create.", + "description": "Number of retries per request. Defaults to 3 on creation.", "default": 3, "type": "integer", "minimum": 0, "maximum": 10 }, "enabled": { - "description": "Whether the server tools are available to workflows. Applied server-side as true when omitted on create.", + "description": "Whether workflows can use the server's tools. Defaults to true on creation.", "default": true, "type": "boolean" }, @@ -5540,7 +5702,7 @@ "maxLength": 2000 }, "transport": { - "description": "Transport used to communicate with the server. Applied server-side as `streamable-http` when omitted on create.", + "description": "Transport protocol. Defaults to `streamable-http` on creation.", "default": "streamable-http", "type": "string", "enum": ["streamable-http"] @@ -5570,21 +5732,21 @@ } }, "timeout": { - "description": "Per-request timeout in milliseconds. Applied server-side as 30000 when omitted on create.", + "description": "Per-request timeout in milliseconds. Defaults to 30000 on creation.", "default": 30000, "type": "integer", "minimum": 1000, "maximum": 300000 }, "retries": { - "description": "Number of retries per request. Applied server-side as 3 when omitted on create.", + "description": "Number of retries per request. Defaults to 3 on creation.", "default": 3, "type": "integer", "minimum": 0, "maximum": 10 }, "enabled": { - "description": "Whether the server tools are available to workflows. Applied server-side as true when omitted on create.", + "description": "Whether workflows can use the server's tools. Defaults to true on creation.", "default": true, "type": "boolean" }, @@ -8048,6 +8210,7 @@ "providerId": { "type": "string", "enum": [ + "github-repositories", "google-email", "google-drive", "google-docs", @@ -8527,8 +8690,8 @@ }, "keyType": { "type": "string", - "enum": ["personal", "workspace"], - "description": "Whether the calling key carries the full authority of its owner across their workspaces, or is scoped to one workspace." + "enum": ["personal", "workspace", "oauth_access_token"], + "description": "Whether the calling credential is a personal API key carrying the full authority of its owner across their workspaces, a key scoped to one workspace, or an OAuth access token acting for its user within the scopes it was granted." }, "expiresAt": { "anyOf": [ @@ -8541,13 +8704,13 @@ "type": "null" } ], - "description": "ISO 8601 timestamp when the calling key expires, or null when it never does." + "description": "ISO 8601 timestamp when the calling credential expires, or null when it does not." } }, "required": ["v2Enabled", "keyType", "expiresAt"], "additionalProperties": false, "title": "API capabilities", - "description": "API availability and lifecycle facts about the calling API key." + "description": "API availability and lifecycle facts about the calling credential." }, "GetApiMetaResponse": { "type": "object", @@ -8560,7 +8723,7 @@ "required": ["data"], "additionalProperties": false, "title": "API capabilities response", - "description": "API availability, key type, and expiry for the calling key.", + "description": "API availability, credential type, and expiry for the caller.", "examples": [ { "data": { @@ -8664,7 +8827,7 @@ }, "toolNamesTruncated": { "type": "boolean", - "description": "Whether `toolCount` and `toolNames` under-report. The names are gathered for the whole page under one ceiling, so a page whose servers publish more tools than that ceiling between them reports only part of each server's inventory. Read one server's tool inventory and check that response's own `truncated`, which reports the same ceiling applied to a single server — only an untruncated response is the authoritative set. Unrelated to `nextCursor`, which is how this list says there are further servers." + "description": "Whether the page-wide tool-name limit left some inventories incomplete. Use List Workflow MCP Tools for one server and check its `truncated` flag before treating the inventory as complete. `nextCursor` paginates servers, not tool names." } }, "required": ["data", "nextCursor", "toolNamesTruncated"], @@ -8932,7 +9095,7 @@ }, "truncated": { "type": "boolean", - "description": "Whether this inventory was cut short by the server-side ceiling on how many tools one response may carry. `nextCursor` is null either way — this list takes no `cursor`, so a truncated set cannot be paged past and this flag is the only way to tell a partial inventory from a complete one. A reconciling caller must not treat a truncated set as the full published inventory." + "description": "Whether the tool limit left this inventory incomplete. The list is unpaginated and `nextCursor` remains null even when truncated. Do not treat a truncated inventory as the complete set of published tools." } }, "required": ["data", "nextCursor", "truncated"], @@ -9330,6 +9493,11 @@ "minLength": 1, "maxLength": 2048 }, + "atlassianProduct": { + "description": "Atlassian product to verify; defaults to Jira on create and preserves the saved product on reconnect.", + "type": "string", + "enum": ["jira", "confluence"] + }, "signingSecret": { "description": "Write-only webhook signing secret.", "writeOnly": true, @@ -9434,7 +9602,7 @@ "source": { "type": "string", "enum": ["builtin", "custom"], - "description": "Where the block comes from: `builtin` is the shipped registry, `custom` is a workflow this workspace deployed as a block." + "description": "Block source: `builtin` for built-in blocks, or `custom` for workflows this workspace deployed as blocks." }, "authMode": { "description": "How the block authenticates: `oauth`, `api_key`, or `bot_token`.", @@ -10122,7 +10290,7 @@ "source": { "type": "string", "enum": ["builtin", "custom"], - "description": "Where the block comes from: `builtin` is the shipped registry, `custom` is a workflow this workspace deployed as a block." + "description": "Block source: `builtin` for built-in blocks, or `custom` for workflows this workspace deployed as blocks." }, "authMode": { "description": "How the block authenticates: `oauth`, `api_key`, or `bot_token`.", @@ -10812,7 +10980,7 @@ }, "input": { "default": {}, - "description": "Arguments for the tool, keyed by the parameter ids the tool catalog publishes for it. A parameter whose visibility is `user-only` also accepts an environment-variable reference written as the whole value, `{{VAR_NAME}}`, resolved server-side against the workspace environment; any other value is sent verbatim.", + "description": "Tool arguments keyed by published parameter IDs. For `user-only` parameters, a whole-value `{{VAR_NAME}}` reference resolves a workspace environment variable. Other values pass through unchanged.", "type": "object", "propertyNames": { "type": "string" diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index bb44fceaef5..a148cd88bda 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -29,6 +29,9 @@ "security": [ { "apiKey": [] + }, + { + "oauthBearer": [] } ], "paths": { @@ -36,7 +39,9 @@ "get": { "operationId": "listTables", "summary": "List Tables", - "description": "List tables in a workspace with optional folder filtering, search, sorting, and an opaque cursor envelope. `scope=archived` lists tables a `DELETE` archived, which `POST /api/v2/tables/{tableId}/restore` can bring back. A workspace folder tree over 10,000 folders is a `413`.", + "description": "List active tables with folder filtering, search, sorting, and cursor pagination. Use `scope=archived` to find tables available for restoration. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "tables.list", + "x-oauth-scope": "api:read", "tags": ["Tables"], "parameters": [ { @@ -67,9 +72,9 @@ "name": "folderPath", "in": "query", "required": false, - "description": "Restrict results to tables in this folder. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", + "description": "Restrict results to tables in this folder. Unknown folder paths contribute no matches.", "schema": { - "description": "Restrict results to tables in this folder. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", + "description": "Restrict results to tables in this folder. Unknown folder paths contribute no matches.", "$ref": "#/components/schemas/FolderPathInput" } }, @@ -183,7 +188,9 @@ "post": { "operationId": "createTable", "summary": "Create Table", - "description": "Create a table with a typed column schema and optional folder placement.", + "description": "Create a table with a typed column schema and optional folder placement.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "tables.create", + "x-oauth-scope": "api:write", "tags": ["Tables"], "requestBody": { "required": true, @@ -255,7 +262,9 @@ "get": { "operationId": "getTable", "summary": "Get Table", - "description": "Retrieve a table with its metadata, column schema, locks, and current job. A workspace folder tree over 10,000 folders is a `413`.", + "description": "Get a table with its metadata, column schema, locks, and current job. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "tables.read", + "x-oauth-scope": "api:read", "tags": ["Tables"], "parameters": [ { @@ -332,7 +341,9 @@ "delete": { "operationId": "deleteTable", "summary": "Delete Table", - "description": "Archive a table and return an explicit deletion acknowledgement. The table is soft-deleted, not erased: its rows are retained and `POST /api/v2/tables/{tableId}/restore` brings it back.", + "description": "Archive a table while retaining its rows. Use List Tables with `scope=archived` to find it and Restore Table to recover it.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "tables.delete", + "x-oauth-scope": "api:write", "tags": ["Tables"], "parameters": [ { @@ -409,7 +420,9 @@ "patch": { "operationId": "updateTable", "summary": "Update Table", - "description": "Rename a table, edit its description, or move it to a canonical folder. At least one mutable field is required; lock flags remain read-only.\n\nNOT atomic: name, description, and folder are written independently, so a 4xx does not mean nothing changed. When at least one field landed before the failure the error body carries `details.applied` naming those fields — retry with only the ones missing from it. Its absence means nothing was applied.\n\nA workspace folder tree over 10,000 folders is a `413`.", + "description": "Rename a table, edit its description, or move it to a folder. Fields are saved independently: a failed request may leave partial changes. `error.details.applied` lists saved fields; retry only the remaining fields. If absent, nothing changed. Lock flags are read-only. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "tables.update", + "x-oauth-scope": "api:write", "tags": ["Tables"], "parameters": [ { @@ -494,7 +507,9 @@ "post": { "operationId": "addTableColumn", "summary": "Add Column", - "description": "Add a typed column and return the complete resulting table schema.", + "description": "Add a typed column and return the complete resulting table schema.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "tables.columns.add", + "x-oauth-scope": "api:write", "tags": ["Tables"], "parameters": [ { @@ -577,7 +592,9 @@ "patch": { "operationId": "updateTableColumn", "summary": "Update Column", - "description": "Update a column by name and return the complete resulting table schema.", + "description": "Update a column by name and return the complete resulting table schema.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "tables.columns.update", + "x-oauth-scope": "api:write", "tags": ["Tables"], "parameters": [ { @@ -660,7 +677,9 @@ "delete": { "operationId": "deleteTableColumn", "summary": "Delete Column", - "description": "Delete a column by name while preserving at least one table column.", + "description": "Delete a column by name while preserving at least one table column.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "tables.columns.delete", + "x-oauth-scope": "api:write", "tags": ["Tables"], "parameters": [ { @@ -745,7 +764,9 @@ "get": { "operationId": "listTableRows", "summary": "List Rows", - "description": "List a plain cursor page in default row order. Pages are capped at 5MB by default and may contain fewer rows than the requested limit; continue until nextCursor is null. Use the query endpoint for predicate filtering and sorting. Set `includeRunState=true` to attach each row's per-workflow-group run outcomes; the row limit is capped when it is set.", + "description": "List rows in default order with cursor pagination. Pages default to a 5 MB limit and may contain fewer rows than requested; continue until `nextCursor` is null. Use Query Rows for filtering and sorting. `includeRunState=true` adds per-group run outcomes and reduces the row limit.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "tables.rows.list", + "x-oauth-scope": "api:read", "tags": ["Tables"], "parameters": [ { @@ -854,7 +875,9 @@ "post": { "operationId": "createTableRows", "summary": "Create Rows", - "description": "Insert one row with a data object or insert a bounded batch with a rows array. Cell keys are column names.", + "description": "Insert one row with a data object or insert a bounded batch with a rows array. Cell keys are column names.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "tables.rows.create", + "x-oauth-scope": "api:write", "tags": ["Tables"], "parameters": [ { @@ -937,7 +960,9 @@ "patch": { "operationId": "updateTableRows", "summary": "Update Rows by Filter", - "description": "Apply the same partial data patch to every row matching a non-empty predicate.", + "description": "Apply the same partial data patch to every row matching a non-empty predicate.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "tables.rows.update_many", + "x-oauth-scope": "api:write", "tags": ["Tables"], "parameters": [ { @@ -1020,7 +1045,9 @@ "delete": { "operationId": "deleteTableRows", "summary": "Delete Rows", - "description": "Delete rows by a non-empty predicate or an explicit bounded list of row identifiers.", + "description": "Delete rows by a non-empty predicate or an explicit bounded list of row identifiers.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "tables.rows.delete_many", + "x-oauth-scope": "api:write", "tags": ["Tables"], "parameters": [ { @@ -1105,7 +1132,9 @@ "get": { "operationId": "getTableRow", "summary": "Get Row", - "description": "Retrieve one row by identifier. Set `includeRunState=true` to attach the row's per-workflow-group run outcomes.", + "description": "Get one row by identifier. Set `includeRunState=true` to attach the row's per-workflow-group run outcomes.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "tables.rows.read", + "x-oauth-scope": "api:read", "tags": ["Tables"], "parameters": [ { @@ -1200,7 +1229,9 @@ "patch": { "operationId": "updateTableRow", "summary": "Update Row", - "description": "Merge a partial data patch into one row by identifier.", + "description": "Merge a partial data patch into one row by identifier.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "tables.rows.update", + "x-oauth-scope": "api:write", "tags": ["Tables"], "parameters": [ { @@ -1294,7 +1325,9 @@ "delete": { "operationId": "deleteTableRow", "summary": "Delete Row", - "description": "Delete one row by identifier.", + "description": "Delete one row by identifier.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "tables.rows.delete", + "x-oauth-scope": "api:write", "tags": ["Tables"], "parameters": [ { @@ -1384,7 +1417,9 @@ "post": { "operationId": "upsertTableRow", "summary": "Upsert Row", - "description": "Insert a row or update the existing row that conflicts on a selected unique column.\n\nWARNING — the update branch REPLACES the row, it does not merge. `data` is the complete new row value, so every column you omit is cleared on the matched row. Send the full row here, or use `PATCH /api/v2/tables/{tableId}/rows/{rowId}` to change a subset.", + "description": "Insert a row or replace the row matching a selected unique column. On replacement, omitted columns are cleared; send the complete row. Use Update Row for a partial patch.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "tables.rows.upsert", + "x-oauth-scope": "api:write", "tags": ["Tables"], "parameters": [ { @@ -1469,7 +1504,9 @@ "post": { "operationId": "queryTableRows", "summary": "Query Rows", - "description": "Query rows with an optional typed predicate, ordered sort specification, and opaque cursor pagination. A predicate may be one condition or an `all`/`any` group; omit it to match every row. Bounded pages are capped at 5MB by default and may contain fewer rows than the requested limit; continue until nextCursor is null. A predicate larger than the request-body ceiling is a `413`. Set `includeRunState: true` to attach each row's per-workflow-group run outcomes; the row limit is capped when it is set. Row totals live on the companion `POST /api/v2/tables/{tableId}/query/count`, which is a separate snapshot — a caller needing a consistent pair should take the count first and treat it as a floor.", + "description": "Query rows with typed predicates, sorting, and cursor pagination. Omit the predicate to match all rows. Pages default to a 5 MB limit; continue until `nextCursor` is null. Oversized predicates return `413`. `includeRunState` adds per-group outcomes and reduces the row limit. Counts are read separately and can differ from paged results if rows change.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "tables.rows.query", + "x-oauth-scope": "api:read", "tags": ["Tables"], "parameters": [ { @@ -1551,7 +1588,9 @@ "post": { "operationId": "countTableRows", "summary": "Count Rows", - "description": "Count the rows matching a typed predicate across the entire table. A predicate may be one condition or an `all`/`any` group. The paged reads carry no total, and `rowCount` on the table resource counts every row rather than the matches. Omit the predicate to count the whole table. A predicate larger than the request-body ceiling is a `413`.", + "description": "Count rows matching a typed predicate, or omit the predicate to count all rows. The count is read separately from row pages and can change between requests. Oversized predicates return `413`.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "tables.rows.query", + "x-oauth-scope": "api:read", "tags": ["Tables"], "parameters": [ { @@ -1633,7 +1672,9 @@ "get": { "operationId": "listTableViews", "summary": "List Views", - "description": "List the bounded set of saved table views, with references to removed columns pruned on read. The bounded set is returned in one page; `nextCursor` is always null.", + "description": "List saved table views, omitting references to removed columns. Returns the complete set in one page; `nextCursor` is always null.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "tables.views.list", + "x-oauth-scope": "api:read", "tags": ["Tables"], "parameters": [ { @@ -1707,7 +1748,9 @@ "post": { "operationId": "createTableView", "summary": "Create View", - "description": "Save a filter, sort, and column layout as a named presentation of a table.", + "description": "Save a filter, sort, and column layout as a named presentation of a table.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "tables.views.create", + "x-oauth-scope": "api:write", "tags": ["Tables"], "parameters": [ { @@ -1789,7 +1832,9 @@ "get": { "operationId": "getTableView", "summary": "Get View", - "description": "Retrieve one saved table view by identifier.", + "description": "Get one saved table view by identifier.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "tables.views.read", + "x-oauth-scope": "api:read", "tags": ["Tables"], "parameters": [ { @@ -1874,7 +1919,9 @@ "patch": { "operationId": "updateTableView", "summary": "Update View", - "description": "Rename a view, replace or shallow-merge its configuration, or promote it to the table default.", + "description": "Rename a view, replace or shallow-merge its configuration, or promote it to the table default.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "tables.views.update", + "x-oauth-scope": "api:write", "tags": ["Tables"], "parameters": [ { @@ -1965,7 +2012,9 @@ "delete": { "operationId": "deleteTableView", "summary": "Delete View", - "description": "Delete a saved presentation without changing any table rows.", + "description": "Delete a saved presentation without changing any table rows.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "tables.views.delete", + "x-oauth-scope": "api:write", "tags": ["Tables"], "parameters": [ { @@ -2052,7 +2101,9 @@ "get": { "operationId": "listTableWorkflowGroups", "summary": "List Workflow Groups", - "description": "List the workflow and enrichment groups that can be dispatched for a table. The bounded set is returned in one page; `nextCursor` is always null.", + "description": "List the workflow and enrichment groups that can be dispatched for a table. Returns the complete set in one page; `nextCursor` is always null.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "tables.groups.list", + "x-oauth-scope": "api:read", "tags": ["Tables"], "parameters": [ { @@ -2126,7 +2177,9 @@ "post": { "operationId": "addTableWorkflowGroup", "summary": "Add Workflow Group", - "description": "Bind a workflow or enrichment to the table and create the columns populated by its outputs.", + "description": "Bind a workflow or enrichment to the table and create the columns populated by its outputs.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "tables.groups.create", + "x-oauth-scope": "api:write", "tags": ["Tables"], "parameters": [ { @@ -2209,7 +2262,9 @@ "patch": { "operationId": "updateTableWorkflowGroup", "summary": "Update Workflow Group", - "description": "Restructure a workflow group, its producer, outputs, or execution behavior. Repointing the group at a different workflow concurrently invalidates the resolved output types and returns `409` — retry the update.", + "description": "Restructure a workflow group, its producer, outputs, or execution behavior. Repointing the group at a different workflow concurrently invalidates the resolved output types and returns `409` — retry the update.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "tables.groups.update", + "x-oauth-scope": "api:write", "tags": ["Tables"], "parameters": [ { @@ -2295,7 +2350,9 @@ "delete": { "operationId": "deleteTableWorkflowGroup", "summary": "Delete Workflow Group", - "description": "Delete a workflow group and every table column populated by that group.", + "description": "Delete a workflow group and every table column populated by that group.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "tables.groups.delete", + "x-oauth-scope": "api:write", "tags": ["Tables"], "parameters": [ { @@ -2380,7 +2437,9 @@ "post": { "operationId": "createTableDispatch", "summary": "Create Run Dispatch", - "description": "Asynchronously run workflow or enrichment groups across all rows or a selected row subset. Poll the returned `dispatchId` with `GET /api/v2/tables/{tableId}/dispatches/{dispatchId}` until its status is `complete` or `canceled`, and cancel it with `DELETE` on the same path. A `null` `dispatchId` means the run settled inline and there is nothing to poll.", + "description": "Start workflow or enrichment groups across all rows or selected rows. Poll Get Run Dispatch until `complete` or `canceled`. A null `dispatchId` means no dispatch is available to poll; check row outcomes with `includeRunState`. Use Cancel Run Dispatch to stop further scheduling.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "tables.runs.start", + "x-oauth-scope": "api:write", "tags": ["Tables"], "parameters": [ { @@ -2460,7 +2519,9 @@ "get": { "operationId": "listTableDispatches", "summary": "List Active Run Dispatches", - "description": "List the run dispatches still in flight on one table. Bounded by the dispatcher rather than by a page size, so this list is unpaginated and `nextCursor` is always null. A settled dispatch is read by identifier.", + "description": "List in-flight run dispatches for a table in one page; `nextCursor` is always null. Use Get Run Dispatch to read a settled dispatch.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "tables.runs.read", + "x-oauth-scope": "api:read", "tags": ["Tables"], "parameters": [ { @@ -2536,7 +2597,9 @@ "post": { "operationId": "runRowEnrichment", "summary": "Run Enrichment For One Row", - "description": "Asynchronously run one workflow or enrichment group for one table row. Poll the returned `dispatchId` with `GET /api/v2/tables/{tableId}/dispatches/{dispatchId}`; a `null` `dispatchId` means the cell already settled inline.", + "description": "Start one workflow or enrichment group for a table row. Poll Get Run Dispatch using the returned `dispatchId`. A null `dispatchId` means no dispatch is available to poll; check row outcomes with `includeRunState`.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "tables.runs.start", + "x-oauth-scope": "api:write", "tags": ["Tables"], "parameters": [ { @@ -2638,7 +2701,9 @@ "get": { "operationId": "getRowEnrichment", "summary": "Get Enrichment Run Detail", - "description": "Retrieve the provider cascade behind one enrichment cell: every configured provider in cascade order, each one's status, hosted-key cost, and duration, plus which provider produced the match. `null` means the cell has never run, or ran before cascade detail was recorded — distinct from a `404`, which means the table, row, or group does not exist.", + "description": "Get an enrichment cell's provider attempts, statuses, hosted-key costs, durations, and matching provider. Null means no run detail was recorded; `404` means the table, row, or group does not exist.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "tables.rows.read", + "x-oauth-scope": "api:read", "tags": ["Tables"], "parameters": [ { @@ -2736,7 +2801,9 @@ "post": { "operationId": "searchTableRows", "summary": "Search Rows", - "description": "Text-search every cell case-insensitively for the substring `q`, optionally within a predicate-filtered and sorted view. This is TEXT search, not the structured predicate read: `POST /api/v2/tables/{tableId}/query` is that one, and on this surface `query` always means a structured predicate while `search` always means text.\n\nIt returns cell COORDINATES — `{ ordinal, rowId, column }` — and never row data. `ordinal` is the row's zero-based index in the same filtered, sorted view `POST /query` pages, so read the rows themselves through that. The result is uncursored and capped: at most 1000 matches come back and `truncated` is `true` when more matched than were returned. There is no cursor to page with — narrow `q` or the predicate instead.", + "description": "Search cell text for a case-insensitive substring within an optional filtered and sorted view. Returns cell coordinates, not row data; `ordinal` matches the view used by Query Rows. Results are unpaginated and capped at 1000. If `truncated` is true, narrow the search or predicate.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "tables.rows.search", + "x-oauth-scope": "api:read", "tags": ["Tables"], "parameters": [ { @@ -2818,7 +2885,9 @@ "post": { "operationId": "createTableImport", "summary": "Create Table Import", - "description": "Create a durable CSV import. Upload sources receive signed transfer instructions; workspace-file sources begin processing directly.", + "description": "Create a CSV import. Upload sources receive signed transfer instructions; workspace-file sources start processing directly.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "tables.imports.create", + "x-oauth-scope": "api:write", "tags": ["Tables"], "requestBody": { "required": true, @@ -2893,7 +2962,9 @@ "get": { "operationId": "getTableImport", "summary": "Get Table Import", - "description": "Read progress and terminal state for a durable table import.\n\nAn upload-backed import has no durable record until its upload completes, so send the signed upload control token to read it during the `uploading` phase; without the token that phase is a `404`.", + "description": "Get an import's progress and status. During `uploading`, the signed upload token is required; omitting it returns `404`.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "tables.imports.read", + "x-oauth-scope": "api:read", "tags": ["Tables"], "parameters": [ { @@ -2979,7 +3050,9 @@ "delete": { "operationId": "cancelTableImport", "summary": "Cancel Table Import", - "description": "Cancel an upload or processing import without rolling back committed row batches.\n\nAn import that is not in a cancelable state, including an `expired` one, is a `409` naming the current status. An unknown or already-purged import id is a `404`.", + "description": "Cancel an upload or processing import. Committed row batches remain. Non-cancelable states, including `expired`, return `409`; unknown or purged imports return `404`.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "tables.imports.cancel", + "x-oauth-scope": "api:write", "tags": ["Tables"], "parameters": [ { @@ -3070,7 +3143,9 @@ "post": { "operationId": "createTableImportPartUrls", "summary": "Create Table Import Part URLs", - "description": "Issue short-lived signed PUT URLs for a bounded set of multipart part numbers.\n\nThe import must still be `uploading`; one that has moved on, including an `expired` one, is a `409` naming the current status. An unknown or already-purged import id is a `404`.", + "description": "Create signed URLs for multipart upload parts. Requires the `uploading` state; other states return `409`. Unknown or purged imports return `404`.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "tables.imports.create_parts", + "x-oauth-scope": "api:write", "tags": ["Tables"], "parameters": [ { @@ -3178,7 +3253,9 @@ "post": { "operationId": "completeTableImportUpload", "summary": "Complete Table Import Upload", - "description": "Verify or assemble the uploaded CSV and begin processing with the same import id.\n\nAn import no longer awaiting an upload, including an `expired` one, is a `409` naming the current status. An unknown or already-purged import id is a `404`.", + "description": "Verify or assemble uploaded CSV bytes and start processing under the same import ID. Requires an import awaiting upload completion; other states return `409`. Unknown or purged imports return `404`.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "tables.imports.complete", + "x-oauth-scope": "api:write", "tags": ["Tables"], "parameters": [ { @@ -3272,7 +3349,9 @@ "post": { "operationId": "createTableExport", "summary": "Create Table Export", - "description": "Create a durable CSV or JSON export that completes inline for small tables and queues larger work.", + "description": "Create a CSV or JSON export. Exports of small tables finish during the request; larger exports run asynchronously.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "tables.exports.create", + "x-oauth-scope": "api:write", "tags": ["Tables"], "parameters": [ { @@ -3357,7 +3436,9 @@ "get": { "operationId": "getTableExport", "summary": "Get Table Export", - "description": "Read progress and terminal state for a durable table export.", + "description": "Get a table export's progress and status.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "tables.exports.read", + "x-oauth-scope": "api:read", "tags": ["Tables"], "parameters": [ { @@ -3443,7 +3524,9 @@ "delete": { "operationId": "cancelTableExport", "summary": "Cancel Table Export", - "description": "Cancel an export that has not reached a terminal state.", + "description": "Cancel an export that is still in progress.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "tables.exports.cancel", + "x-oauth-scope": "api:write", "tags": ["Tables"], "parameters": [ { @@ -3534,7 +3617,9 @@ "get": { "operationId": "downloadTableExport", "summary": "Download Table Export", - "description": "Return a short-lived signed download URL for a completed table export.\n\nThe export must have reached `completed`; one still processing, failed, or canceled is a `409` naming the current status. An export whose file is no longer available is a `404`, not a `410`.", + "description": "Get a short-lived signed download URL for a completed export. Other states return `409`; an unavailable export file returns `404`.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "tables.exports.download", + "x-oauth-scope": "api:read", "tags": ["Tables"], "parameters": [ { @@ -3625,7 +3710,9 @@ "post": { "operationId": "cancelTableRuns", "summary": "Cancel Column Runs", - "description": "Stop in-flight and pending workflow or enrichment cell runs across the table or one selected row.", + "description": "Stop in-flight and pending workflow or enrichment cell runs across the table or one selected row.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "tables.runs.cancel", + "x-oauth-scope": "api:write", "tags": ["Tables"], "parameters": [ { @@ -3707,7 +3794,9 @@ "get": { "operationId": "listTablesFolders", "summary": "List Folders", - "description": "List table folders, optionally restricting the result to direct children of a canonical parent path. The bounded set is returned in one page; `nextCursor` is always null.", + "description": "List table folders, optionally limiting results to direct children of a parent path. Returns the complete set in one page; `nextCursor` is always null.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "tables.folders.list", + "x-oauth-scope": "api:read", "tags": ["Tables"], "parameters": [ { @@ -3726,9 +3815,9 @@ "name": "parentPath", "in": "query", "required": false, - "description": "Restrict results to direct children of this parent path. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", + "description": "Restrict results to direct children of this parent path. Unknown folder paths contribute no matches.", "schema": { - "description": "Restrict results to direct children of this parent path. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", + "description": "Restrict results to direct children of this parent path. Unknown folder paths contribute no matches.", "$ref": "#/components/schemas/FolderPathInput" } }, @@ -3820,7 +3909,9 @@ "post": { "operationId": "createTablesFolder", "summary": "Create Folder", - "description": "Create one table-folder leaf whose parent path already exists.", + "description": "Create one table-folder leaf whose parent path already exists.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "tables.folders.create", + "x-oauth-scope": "api:write", "tags": ["Tables"], "requestBody": { "required": true, @@ -3890,7 +3981,9 @@ "patch": { "operationId": "relocateTablesFolder", "summary": "Rename or Move Folder", - "description": "Rename or move a table folder and update all descendant paths.", + "description": "Rename or move a table folder and update all descendant paths.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "tables.folders.update", + "x-oauth-scope": "api:write", "tags": ["Tables"], "requestBody": { "required": true, @@ -3960,7 +4053,9 @@ "delete": { "operationId": "deleteTablesFolder", "summary": "Delete Folder", - "description": "Delete an empty table folder, or recursively delete its descendants and tables when explicitly requested.", + "description": "Archive an empty folder, or set `recursive=true` to archive its tables and subfolders. Use Restore Folder to recover the archived contents.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "tables.folders.delete", + "x-oauth-scope": "api:write", "tags": ["Tables"], "parameters": [ { @@ -4070,7 +4165,9 @@ "post": { "operationId": "restoreTablesFolder", "summary": "Restore Folder", - "description": "Un-archive a table folder a recursive `DELETE` archived, along with every subfolder and table archived with it. Address it by the path it held when it was deleted. The restore may legally land it elsewhere: a folder whose parent is still archived is re-rooted to `/`, and a name an active sibling has taken meanwhile is deduplicated — so read the returned folder's `path` rather than assuming the requested one. A path that is not archived answers `404`. `DELETE /api/v2/tables/folders` returns the path it archived, which is the value to keep and send here; unlike the files surface, `GET /api/v2/tables/folders` does not yet list archived folders, so a caller that discards that path cannot recover it over the API.", + "description": "Restore an archived table folder, its descendants, and tables using its former path. An archived parent moves it to the root; name conflicts may change the returned `path`. Non-archived paths return `404`. Save the path from Delete Folder, because List Folders does not include archived table folders.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "tables.folders.restore", + "x-oauth-scope": "api:write", "tags": ["Tables"], "requestBody": { "required": true, @@ -4142,7 +4239,9 @@ "post": { "operationId": "restoreTable", "summary": "Restore Table", - "description": "Un-archive a table a `DELETE` archived, along with the rows, views, and workflow groups archived with it. Find archived tables with `scope=archived` on the table list. Idempotent: a table that is already active is returned unchanged with no audit entry recorded, so a retry after a dropped response cannot look like a failure. A name collision is resolved by renaming, so the restored table may come back under a different `name`.", + "description": "Restore a table and its archived rows, views, and workflow groups. Active tables return unchanged without a new audit event. Name conflicts may change the returned `name`. Find archived tables with List Tables and `scope=archived`.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "tables.restore", + "x-oauth-scope": "api:write", "tags": ["Tables"], "parameters": [ { @@ -4227,7 +4326,9 @@ "post": { "operationId": "bulkUpdateTableRows", "summary": "Bulk Update Rows", - "description": "Apply a distinct partial data patch to each of up to 1000 rows in one request. Each patch merges into its row, so a column absent from `data` is left alone. Membership is atomic: a `rowId` naming no row in this table fails the whole request with a `400` listing the missing identifiers. Use `PATCH /api/v2/tables/{tableId}/rows` when one patch applies to every matching row.", + "description": "Apply separate partial patches to up to 1,000 rows, preserving omitted columns. A row outside the table rejects the entire request with `400` and lists missing IDs. Use Update Rows by Filter to apply one patch to every matching row.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "tables.rows.update_many", + "x-oauth-scope": "api:write", "tags": ["Tables"], "parameters": [ { @@ -4312,7 +4413,9 @@ "get": { "operationId": "getTableDispatch", "summary": "Get Run Dispatch", - "description": "Poll one workflow-column run dispatch by the `dispatchId` the run endpoints returned. Answers in every lifecycle state — `pending`, `dispatching`, `complete`, and `canceled` — so a poller can wait for a run to settle. Per-cell outcomes are read with `includeRunState` on the row endpoints.", + "description": "Get a dispatch's current state. Poll until `complete` or `canceled`; use row reads with `includeRunState` for per-cell outcomes.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "tables.runs.read", + "x-oauth-scope": "api:read", "tags": ["Tables"], "parameters": [ { @@ -4398,7 +4501,9 @@ "delete": { "operationId": "cancelTableDispatch", "summary": "Cancel Run Dispatch", - "description": "Cancel one run dispatch by the `dispatchId` the run endpoint returned. This stops the scheduler: the dispatcher observes the cancellation at its next iteration and enqueues no further cells. Cells already handed to the queue are NOT canceled here — nothing links a queued cell back to the dispatch that enqueued it — so use `POST /api/v2/tables/{tableId}/cancel-runs` to stop work already in flight. Idempotent: a dispatch already `complete` or `canceled` is returned unchanged.", + "description": "Stop a dispatch from scheduling more cells. Already queued or running cells continue; use Cancel Column Runs to stop them. Completed or canceled dispatches return unchanged.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "tables.runs.cancel", + "x-oauth-scope": "api:write", "tags": ["Tables"], "parameters": [ { @@ -4486,7 +4591,9 @@ "post": { "operationId": "moveTables", "summary": "Move Tables and Folders", - "description": "Move up to 100 tables and table folders into one destination folder in a single authorized request. Folders are named by canonical path, and `null` or `/` moves to the workspace root. Best-effort per item: a table filed inside a selected folder is reported in `skipped` because the folder already carries it, an entry that resolves to nothing lands in `notFound`, and an item refused by a lock or a folder cycle lands in `failed` with a reason. An invalid destination fails the whole request before anything moves.", + "description": "Move up to 100 tables and folders to one destination. Items succeed or fail independently: covered tables are `skipped`, missing items are `notFound`, and lock or cycle failures include reasons in `failed`. An invalid destination rejects the request before any move.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "tables.bulk_move", + "x-oauth-scope": "api:write", "tags": ["Tables"], "requestBody": { "required": true, @@ -4555,7 +4662,9 @@ "post": { "operationId": "bulkDeleteTables", "summary": "Bulk Delete Tables and Folders", - "description": "Archive up to 100 tables and delete table folders in a single authorized request. Folders are named by canonical path and each cascades to everything inside it; `deletedItems` reports the totals across every cascade. Archived tables stay recoverable through `POST /api/v2/tables/{tableId}/restore`. Best-effort per item, with the same `skipped` / `notFound` / `failed` dispositions as the bulk move.", + "description": "Archive up to 100 selected tables and folders, including folder contents. Items succeed or fail independently, with `skipped`, `notFound`, and `failed` outcomes. `deletedItems` includes all descendants. Use Restore Table or Restore Folder to recover archived items.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "tables.bulk_delete", + "x-oauth-scope": "api:write", "tags": ["Tables"], "requestBody": { "required": true, @@ -4631,6 +4740,12 @@ "in": "header", "name": "X-API-Key", "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description." + }, + "oauthBearer": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "OAuth 2.0 access token", + "description": "A Sim OAuth access token obtained by a registered client through the authorization-code flow. Each operation declares its required scope: api:read permits reads and searches; api:write also permits changes and execution and implies api:read. Scope requirements follow the application operation, independent of HTTP method or workspace role." } }, "headers": { @@ -4702,7 +4817,7 @@ } }, "Unauthorized": { - "description": "The API key is missing or invalid.", + "description": "The API credential is missing or invalid.", "content": { "application/json": { "schema": { @@ -4711,7 +4826,7 @@ "example": { "error": { "code": "UNAUTHORIZED", - "message": "API key required" + "message": "Authentication required" } } } @@ -4882,6 +4997,49 @@ } }, "schemas": { + "V2ActionableForbiddenDetails": { + "type": "object", + "properties": { + "code": { + "$ref": "#/components/schemas/V2ForbiddenDetailCode" + } + }, + "required": ["code"], + "additionalProperties": { + "description": "Additional context for this refusal." + }, + "title": "Actionable forbidden details", + "description": "Machine-readable cause and optional context for an actionable `403` response." + }, + "V2ForbiddenDetailCode": { + "type": "string", + "enum": [ + "INSUFFICIENT_WORKSPACE_ROLE", + "PERSONAL_API_KEYS_DISABLED", + "WORKSPACE_KEY_OPERATION_NOT_PERMITTED", + "PRINCIPAL_KIND_NOT_PERMITTED", + "ORGANIZATION_MEMBERSHIP_REQUIRED", + "ORGANIZATION_ADMIN_REQUIRED", + "ENTERPRISE_PLAN_REQUIRED", + "ORGANIZATION_PLAN_REQUIRED", + "AUDIT_LOGS_DISABLED", + "SKILL_EDITOR_ACCESS_REQUIRED", + "SECRET_ADMIN_ACCESS_REQUIRED", + "WORKSPACE_RESOURCE_LIMIT_REACHED", + "PUBLIC_SHARING_NOT_ALLOWED", + "CREDENTIAL_ADMIN_ACCESS_REQUIRED", + "MCP_SERVER_URL_NOT_ALLOWED", + "WORKSPACE_PLAN_CAPABILITY_REQUIRED", + "CHAT_AUTH_MODE_NOT_PERMITTED", + "CONNECTOR_MANAGED_RESOURCE_READ_ONLY", + "PERMISSION_GROUP_CAPABILITY_BLOCKED", + "INTEGRATION_NOT_ALLOWED", + "INSUFFICIENT_SCOPE", + "SCIM_MANAGED_MEMBERSHIP" + ], + "title": "Forbidden detail code", + "description": "Stable cause code for an actionable `403` response." + }, "V2Error": { "type": "object", "properties": { @@ -4897,7 +5055,15 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `ORGANIZATION_PLAN_REQUIRED` — The organization has no active organization subscription (Pro for Teams, Max for Teams, or Enterprise).\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\n- `WORKSPACE_PLAN_CAPABILITY_REQUIRED` — The workspace's plan does not include a capability this request depends on. The message names the capability; upgrading the workspace's plan is the remedy.\n- `CHAT_AUTH_MODE_NOT_PERMITTED` — The workspace's permission group does not allow the chat authentication mode the request selected. A mode already saved on the deployment may still be re-saved; changing to a disallowed one cannot.\n- `CONNECTOR_MANAGED_RESOURCE_READ_ONLY` — This resource is managed by a knowledge base connector and cannot be edited directly. Change it at the source and re-sync, or exclude the document from the connector.\n- `PERMISSION_GROUP_CAPABILITY_BLOCKED` — The caller's permission group does not allow this capability. The message names it; an organization admin controls the group.\n- `INTEGRATION_NOT_ALLOWED` — The integration this request names is outside the workspace's allowed set. An organization admin controls the permission group's integration allowlist, and a self-hosted deployment can narrow it further with ALLOWED_INTEGRATIONS." + "description": "Structured error context whose keys depend on the error. Actionable `403` responses use the `V2ActionableForbiddenDetails` shape; validation failures may return issue arrays instead.", + "anyOf": [ + { + "$ref": "#/components/schemas/V2ActionableForbiddenDetails" + }, + { + "description": "Other structured context defined by the specific error." + } + ] } }, "required": ["code", "message"], @@ -6039,7 +6205,7 @@ }, "TablePredicate": { "title": "Table predicate", - "description": "Recursive predicate tree. Each group node is exactly one non-empty `all` or `any` array whose members are further groups or `{ field, op, value }` conditions; the root must be a group, not a bare condition. At most 100 members per group, 10 levels of nesting, and 500 nodes in total. The negating operators include nulls: `ne`, `nin`, `ncontains`, `nlike`, and `nilike` match rows whose column is null or absent, so \"not X\" is not the complement of \"X\" over a nullable column. That holds for every column type, multi-select included. To exclude nulls, `all`-combine the negation with `isNotEmpty` (multi-select) or `isNotNull`. Comparison: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`. Membership: `in`, `nin` (array operand). Emptiness: `isEmpty`, `isNotEmpty`, `isNull`, `isNotNull` (no operand). Substring, always case-insensitive, operand matched literally: `contains`, `ncontains`, `startsWith`, `endsWith`. Pattern: `like`/`nlike` (case-sensitive), `ilike`/`nilike` (case-insensitive). **`*` is the only wildcard** and stands for any run of characters; `%`, `_`, and backslash match themselves. Use `like: \"Hi*\"`, not `like: \"Hi%\"`. A `select` column compares by option id and restricts its operators: single-select accepts `eq`, `ne`, `in`, `nin`; multi-select accepts `contains`, `ncontains`. Option names are accepted as operands and resolved to ids.", + "description": "Recursive non-empty `all`/`any` groups containing groups or conditions; the root cannot be a condition. Limits: 100 members per group, 10 levels, and 500 nodes. The negating operators include nulls and absent cells, multi-select included; combine with `isNotNull` or `isNotEmpty` to exclude them. Pattern operators use `*` as the only wildcard; `%`, `_`, and backslash are literal. Select operators: single-select uses `eq`/`ne`/`in`/`nin`; multi-select uses `contains`/`ncontains`; option names resolve to IDs. Full operand rules are documented on `op`.", "type": "object", "oneOf": [ { @@ -6092,7 +6258,7 @@ "isNull", "isNotNull" ], - "description": "Comparison operator. The `TablePredicate` schema description carries the grammar for all of them." + "description": "Operators: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`; `in`/`nin` take arrays; `isEmpty`, `isNotEmpty`, `isNull`, and `isNotNull` take no operand. Text operators are `contains`, `ncontains`, `startsWith`, `endsWith`, `like`, `nlike`, `ilike`, and `nilike`. Contains variants are case-insensitive and literal; `like`/`nlike` are case-sensitive, while `ilike`/`nilike` are case-insensitive. `*` is the only wildcard; `%`, `_`, and backslash are literal. For `select` columns, single-select accepts `eq`, `ne`, `in`, `nin`; multi-select accepts `contains`, `ncontains`. Option names resolve to IDs." }, "value": { "description": "Operand. A scalar for the comparison operators, an array of at most 1000 entries for `in`/`nin`, a pattern for the matching operators, and omitted for `isEmpty`/`isNotEmpty`/`isNull`/`isNotNull`." @@ -6158,7 +6324,7 @@ "isNull", "isNotNull" ], - "description": "Comparison operator. The `TablePredicate` schema description carries the grammar for all of them." + "description": "Operators: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`; `in`/`nin` take arrays; `isEmpty`, `isNotEmpty`, `isNull`, and `isNotNull` take no operand. Text operators are `contains`, `ncontains`, `startsWith`, `endsWith`, `like`, `nlike`, `ilike`, and `nilike`. Contains variants are case-insensitive and literal; `like`/`nlike` are case-sensitive, while `ilike`/`nilike` are case-insensitive. `*` is the only wildcard; `%`, `_`, and backslash are literal. For `select` columns, single-select accepts `eq`, `ne`, `in`, `nin`; multi-select accepts `contains`, `ncontains`. Option names resolve to IDs." }, "value": { "description": "Operand. A scalar for the comparison operators, an array of at most 1000 entries for `in`/`nin`, a pattern for the matching operators, and omitted for `isEmpty`/`isNotEmpty`/`isNull`/`isNotNull`." @@ -6453,7 +6619,7 @@ }, "TablePredicateInput": { "title": "Table predicate input", - "description": "A single `{ field, op, value }` condition or a recursive `all`/`any` group; either form is normalized to a grouped predicate after validation. At most 100 members per group, 10 levels of nesting, and 500 nodes in total. The negating operators include nulls: `ne`, `nin`, `ncontains`, `nlike`, and `nilike` match rows whose column is null or absent, so \"not X\" is not the complement of \"X\" over a nullable column. That holds for every column type, multi-select included. To exclude nulls, `all`-combine the negation with `isNotEmpty` (multi-select) or `isNotNull`. Comparison: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`. Membership: `in`, `nin` (array operand). Emptiness: `isEmpty`, `isNotEmpty`, `isNull`, `isNotNull` (no operand). Substring, always case-insensitive, operand matched literally: `contains`, `ncontains`, `startsWith`, `endsWith`. Pattern: `like`/`nlike` (case-sensitive), `ilike`/`nilike` (case-insensitive). **`*` is the only wildcard** and stands for any run of characters; `%`, `_`, and backslash match themselves. Use `like: \"Hi*\"`, not `like: \"Hi%\"`. A `select` column compares by option id and restricts its operators: single-select accepts `eq`, `ne`, `in`, `nin`; multi-select accepts `contains`, `ncontains`. Option names are accepted as operands and resolved to ids.", + "description": "One condition or a recursive `all`/`any` group, normalized to a grouped predicate. Limits: 100 members per group, 10 levels, and 500 nodes. The negating operators include nulls and absent cells, multi-select included; combine with `isNotNull` or `isNotEmpty` to exclude them. Pattern operators use `*` as the only wildcard; `%`, `_`, and backslash are literal. Select operators: single-select uses `eq`/`ne`/`in`/`nin`; multi-select uses `contains`/`ncontains`; option names resolve to IDs. Full operand rules are documented on `op`.", "oneOf": [ { "type": "object", @@ -6505,7 +6671,7 @@ "isNull", "isNotNull" ], - "description": "Comparison operator. The `TablePredicate` schema description carries the grammar for all of them." + "description": "Operators: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`; `in`/`nin` take arrays; `isEmpty`, `isNotEmpty`, `isNull`, and `isNotNull` take no operand. Text operators are `contains`, `ncontains`, `startsWith`, `endsWith`, `like`, `nlike`, `ilike`, and `nilike`. Contains variants are case-insensitive and literal; `like`/`nlike` are case-sensitive, while `ilike`/`nilike` are case-insensitive. `*` is the only wildcard; `%`, `_`, and backslash are literal. For `select` columns, single-select accepts `eq`, `ne`, `in`, `nin`; multi-select accepts `contains`, `ncontains`. Option names resolve to IDs." }, "value": { "description": "Operand. A scalar for the comparison operators, an array of at most 1000 entries for `in`/`nin`, a pattern for the matching operators, and omitted for `isEmpty`/`isNotEmpty`/`isNull`/`isNotNull`." @@ -6571,7 +6737,7 @@ "isNull", "isNotNull" ], - "description": "Comparison operator. The `TablePredicate` schema description carries the grammar for all of them." + "description": "Operators: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`; `in`/`nin` take arrays; `isEmpty`, `isNotEmpty`, `isNull`, and `isNotNull` take no operand. Text operators are `contains`, `ncontains`, `startsWith`, `endsWith`, `like`, `nlike`, `ilike`, and `nilike`. Contains variants are case-insensitive and literal; `like`/`nlike` are case-sensitive, while `ilike`/`nilike` are case-insensitive. `*` is the only wildcard; `%`, `_`, and backslash are literal. For `select` columns, single-select accepts `eq`, `ne`, `in`, `nin`; multi-select accepts `contains`, `ncontains`. Option names resolve to IDs." }, "value": { "description": "Operand. A scalar for the comparison operators, an array of at most 1000 entries for `in`/`nin`, a pattern for the matching operators, and omitted for `isEmpty`/`isNotEmpty`/`isNull`/`isNotNull`." @@ -6622,7 +6788,7 @@ "isNull", "isNotNull" ], - "description": "Comparison operator. The `TablePredicate` schema description carries the grammar for all of them." + "description": "Operators: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`; `in`/`nin` take arrays; `isEmpty`, `isNotEmpty`, `isNull`, and `isNotNull` take no operand. Text operators are `contains`, `ncontains`, `startsWith`, `endsWith`, `like`, `nlike`, `ilike`, and `nilike`. Contains variants are case-insensitive and literal; `like`/`nlike` are case-sensitive, while `ilike`/`nilike` are case-insensitive. `*` is the only wildcard; `%`, `_`, and backslash are literal. For `select` columns, single-select accepts `eq`, `ne`, `in`, `nin`; multi-select accepts `contains`, `ncontains`. Option names resolve to IDs." }, "value": { "description": "Operand. A scalar for the comparison operators, an array of at most 1000 entries for `in`/`nin`, a pattern for the matching operators, and omitted for `isEmpty`/`isNotEmpty`/`isNull`/`isNotNull`." @@ -7969,7 +8135,7 @@ "type": "null" } ], - "description": "Background dispatch identifier, or null when execution is inline." + "description": "Run dispatch ID, or null when no dispatch is available to poll. Use row reads with `includeRunState` to check cell outcomes." } }, "required": ["dispatchId"], @@ -8368,7 +8534,7 @@ "type": "integer", "minimum": 0, "maximum": 9007199254740991, - "description": "Lower bound on the source records the CSV parser could not read and dropped, counted as one per parser failure. A single failure can discard more than one record — an unterminated quote swallows the rest of the file and is reported once — so the true loss may be larger. Non-zero means the import is partial even when the status is completed; zero is not a guarantee that nothing was dropped." + "description": "Minimum number of source records dropped by parser failures. One failure can discard multiple records, such as an unterminated quote consuming the rest of the file. A non-zero value means partial import even with `completed` status; zero does not guarantee no loss." }, "cellsRejected": { "type": "integer", @@ -8451,7 +8617,7 @@ "url": { "type": "string", "format": "uri", - "description": "Signed URL to which the file bytes are uploaded. Send the bytes with `PUT` to this URL, including exactly the headers in `headers` and nothing that alters the body. The URL is signed and self-describing — construct it from this field only, never by hand.\n\n**Where this URL points depends on the deployment, and so does what answers you.** When Sim stores objects itself the URL is Sim's own data plane: success is `204` with an empty body, and a failure is the same `{ \"error\": { \"code\", \"message\" } }` envelope as every other v2 response — `400` when the body does not match the size or content type the session was created for, `403` when the token is invalid, expired, or belongs to another session, and `409` when the session is no longer accepting bytes. When object storage is configured — S3, Google Cloud Storage, or Azure Blob — the URL is that provider's own presigned URL, and the provider answers directly: treat **any `2xx` as success** (S3 and GCS answer `200`, Azure `201`), and on failure expect the provider's error document, typically XML, not the v2 envelope. Do not branch on `204` and do not parse a failure as JSON." + "description": "Signed URL to which the file bytes are uploaded. Upload bytes with `PUT` and exactly the supplied headers; never construct or modify the signed URL. Treat any `2xx` as success. Sim-hosted URLs return an empty `204` and v2 JSON errors. Object-storage URLs may return `200` or `201` and provider-specific errors, often XML." }, "headers": { "type": "object", @@ -8610,7 +8776,7 @@ "type": "integer", "minimum": 0, "maximum": 9007199254740991, - "description": "Lower bound on the source records the CSV parser could not read and dropped, counted as one per parser failure. A single failure can discard more than one record — an unterminated quote swallows the rest of the file and is reported once — so the true loss may be larger. Non-zero means the import is partial even when the status is completed; zero is not a guarantee that nothing was dropped." + "description": "Minimum number of source records dropped by parser failures. One failure can discard multiple records, such as an unterminated quote consuming the rest of the file. A non-zero value means partial import even with `completed` status; zero does not guarantee no loss." }, "cellsRejected": { "type": "integer", @@ -8954,7 +9120,7 @@ "type": "integer", "minimum": 0, "maximum": 9007199254740991, - "description": "Lower bound on the source records the CSV parser could not read and dropped, counted as one per parser failure. A single failure can discard more than one record — an unterminated quote swallows the rest of the file and is reported once — so the true loss may be larger. Non-zero means the import is partial even when the status is completed; zero is not a guarantee that nothing was dropped." + "description": "Minimum number of source records dropped by parser failures. One failure can discard multiple records, such as an unterminated quote consuming the rest of the file. A non-zero value means partial import even with `completed` status; zero does not guarantee no loss." }, "cellsRejected": { "type": "integer", @@ -9064,7 +9230,7 @@ "url": { "type": "string", "format": "uri", - "description": "Signed URL for this upload part. Send the bytes with `PUT` to this URL, including exactly the headers in `headers` and nothing that alters the body. The URL is signed and self-describing — construct it from this field only, never by hand.\n\n**Where this URL points depends on the deployment, and so does what answers you.** When Sim stores objects itself the URL is Sim's own data plane: success is `204` with an empty body, and a failure is the same `{ \"error\": { \"code\", \"message\" } }` envelope as every other v2 response — `400` when the body does not match the size or content type the session was created for, `403` when the token is invalid, expired, or belongs to another session, and `409` when the session is no longer accepting bytes. When object storage is configured — S3, Google Cloud Storage, or Azure Blob — the URL is that provider's own presigned URL, and the provider answers directly: treat **any `2xx` as success** (S3 and GCS answer `200`, Azure `201`), and on failure expect the provider's error document, typically XML, not the v2 envelope. Do not branch on `204` and do not parse a failure as JSON.\n\nYou do not need to retain the `ETag` each part upload returns. Unlike a raw S3 multipart flow, completion takes no request body: Sim lists the uploaded parts from the provider itself and reads their entity tags there, so `POST .../complete` only has to happen after every part has been sent." + "description": "Signed URL for this upload part. Upload bytes with `PUT` and exactly the supplied headers; never construct or modify the signed URL. Treat any `2xx` as success. Sim-hosted URLs return an empty `204` and v2 JSON errors. Object-storage URLs may return `200` or `201` and provider-specific errors, often XML. Do not retain part `ETag` values; after every part succeeds, call the completion endpoint without a request body." }, "headers": { "type": "object", @@ -9881,7 +10047,7 @@ }, "status": { "type": "string", - "description": "How this provider ended: `matched`, `no_match`, `skipped`, `error`, or `not_run`. Declared as a string rather than a closed enum because the value is read back out of a schemaless JSONB blob — a member added by a newer runner must widen a client's switch, not fail its read." + "description": "Provider outcome: `matched`, `no_match`, `skipped`, `error`, or `not_run`. Handle unrecognized values, since additional statuses may be returned." }, "cost": { "type": "number", diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index fa265533ea7..2a79bb7be6b 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -33,6 +33,9 @@ "security": [ { "apiKey": [] + }, + { + "oauthBearer": [] } ], "paths": { @@ -40,7 +43,9 @@ "get": { "operationId": "listWorkflows", "summary": "List Workflows", - "description": "List workflows in a workspace with lifecycle scope, folder and deployment filters, search, sorting, and opaque cursor pagination. `scope` defaults to `active`; pass `archived` to list workflows a `DELETE` archived. A workspace folder tree over 10,000 folders is a `413`.", + "description": "List active workflows in a workspace. Use `scope=archived` to find workflows available for restoration. Supports folder and deployment filters, search, sorting, and cursor pagination. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "workflows.list", + "x-oauth-scope": "api:read", "tags": ["Workflows"], "parameters": [ { @@ -71,9 +76,9 @@ "name": "folderPath", "in": "query", "required": false, - "description": "Restrict results to workflows in this folder path. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", + "description": "Restrict results to workflows in this folder path. Unknown folder paths contribute no matches.", "schema": { - "description": "Restrict results to workflows in this folder path. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", + "description": "Restrict results to workflows in this folder path. Unknown folder paths contribute no matches.", "$ref": "#/components/schemas/FolderPathInput" } }, @@ -199,7 +204,9 @@ "post": { "operationId": "createWorkflowV2", "summary": "Create Workflow", - "description": "Create a workflow in a workspace root or canonical workflow folder. The response carries the blocks the platform seeded the workflow with, so the start block's id is available without a second request — attach edges to it directly. A workspace folder tree over 10,000 folders is a `413`.", + "description": "Create a workflow at the workspace root or in a workflow folder. The response includes seeded blocks and their IDs for attaching edges. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "workflows.create", + "x-oauth-scope": "api:write", "tags": ["Workflows"], "requestBody": { "required": true, @@ -274,7 +281,9 @@ "get": { "operationId": "getWorkflowState", "summary": "Get Workflow State", - "description": "Get the editable draft graph of a workflow: blocks, edges, the loop and parallel containers derived from them, and variables. This is the pollable read — it records no audit event, and `HEAD` mirrors `GET`. The payload is **unsanitized**: it carries workspace-scoped `credentialId`, `knowledgeBaseId`, and `tableId` values verbatim, so it is not portable to another workspace. Use `GET /workflows/{workflowId}/export` for a portable, sanitized copy — and note that export is not a read-modify-write source, because sanitizing it drops every credential binding. Unknown members are stripped, so what this returns is exactly the set of keys `PUT /workflows/{workflowId}/state` accepts.", + "description": "Get the editable draft graph, including blocks, edges, loop and parallel containers, and variables. Use this state with Replace Workflow State to preserve workspace bindings; Export Workflow removes those bindings for portability. This read records no audit event, and `HEAD` mirrors `GET`.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "workflows.read", + "x-oauth-scope": "api:read", "tags": ["Workflows"], "parameters": [ { @@ -338,7 +347,9 @@ "put": { "operationId": "replaceWorkflowState", "summary": "Replace Workflow State", - "description": "Replace a workflow’s editable draft graph wholesale. `loops` and `parallels` are accepted but ignored — both are recomputed from `blocks`. Omitting `variables` leaves the stored variables untouched.\n\nLast write wins: concurrent writers are serialized by a row lock, so each lands a complete self-consistent graph and the later one replaces the earlier entirely. There is no partially-written state. Ids are the one conflict that is detected: block, edge, and subflow ids are globally unique, so a body carrying an id another workflow already owns is refused with `409` rather than written.\n\nThis does not change what the deployed endpoint serves. Deployments are immutable versioned snapshots, and no schedule or webhook registration is touched. The only visible consequence is that `needsRedeployment` becomes true; `POST /workflows/{workflowId}/deploy` publishes the draft.\n\n`lint` is advisory and never blocks the write. `lint.fieldIssues` is the most actionable part for a headless builder — it names blocks missing a required field, which fail at run time — and `lint.unresolvedReferences` names credential, resource, tool, and skill values that do not resolve. A workspace API key is rejected with `403`; use a personal API key.\n\nSet `?dryRun=true` to validate and lint without persisting: nothing is written, no audit entry is recorded, and collaborators are not notified. The response carries the same shape and the same validation and `lint` findings the committed write would, with `dryRun: true` — including the warnings the write’s own preparation step raises, and the same `409` when an id is already owned by another workflow. Only `needsRedeployment` differs: it describes the state before the write.", + "description": "Replace the draft graph atomically; concurrent writes are last-write-wins. Recompute containers from blocks and preserve omitted variables. Foreign IDs return `409`; lint is advisory. The live deployment is unchanged. `dryRun=true` validates without saving, auditing, or notifying; `needsRedeployment` describes the pre-write state. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "workflows.state.replace", + "x-oauth-scope": "api:write", "tags": ["Workflows"], "parameters": [ { @@ -437,7 +448,9 @@ "post": { "operationId": "applyWorkflowOperations", "summary": "Apply Workflow Operations", - "description": "Apply a batch of semantic edits — add, edit, delete, and subflow membership changes — to a workflow graph, plus an optional set of block enable/disable changes.\n\nBest-effort per operation, atomic per write. The engine applies what it can to an in-memory graph and reports the rest in `skipped`, each with a machine-readable `type`; exactly one write of the fully-resolved graph then happens, so there is never a partially-applied graph. `deferred` is **not** a failure list: a forward-referencing edge is wired automatically once its target block exists, in this batch or a later one, so re-issuing a deferred edge is wrong.\n\nSet `atomic` to fail closed: any genuine skipped item, or any block input that would be dropped rather than persisted, then aborts before the write and answers `409` with `error.details.code: \"OPERATIONS_NOT_APPLIED\"`, the same `skipped` array, and a `droppedInputs` array, having persisted nothing.\n\nA `block_id` you supply on an `add` or `insert_into_subflow` is only a label unless it is already a UUID: the engine mints one and returns the pairing in `mintedBlockIds`. References between operations in the same batch are remapped for you, so `triage` can be wired up in the same call it is created in — but a later request must use the minted id. Send your own UUIDs when you want an id you chose to survive across requests.\n\nOperation `params` is an open object because the accepted inputs come from the block registry, not from this contract — see the per-operation schemas for the envelope: `inputs` keyed by sub-block id, with `retry`, `triggerMode` and `advancedMode` beside it rather than inside it, and `connections` keyed by source handle. `GET /blocks/{blockId}` publishes the inputs a given block type accepts. The Agent block’s `inputs.tools` value is the important exception to that open catalog shape: it is published here as the named `AgentToolInput` union, covering catalog integrations, workspace custom tools, and MCP tools.\n\n`lint` is advisory and never blocks the write. `lint.fieldIssues` is the most actionable part for a headless builder — it names blocks missing a required field, which fail at run time — and `lint.unresolvedReferences` names credential, resource, tool, and skill values that do not resolve. Those values stay persisted; only `inputValidationErrors` lists inputs that were actually dropped.\n\nAs with `PUT /workflows/{workflowId}/state`, this changes only the draft; deploy to publish it. A workspace API key is rejected with `403`; use a personal API key.\n\nSet `?dryRun=true` to validate and lint without persisting: nothing is written, no audit entry is recorded, and collaborators are not notified. The response carries the same shape and the same validation and `lint` findings the committed write would, with `dryRun: true` — including the warnings the write’s own preparation step raises, and the same `409` when an id is already owned by another workflow. Only `needsRedeployment` differs: it describes the state before the write.", + "description": "Edit the draft graph and block enablement in one write. Inspect `skipped` for failures; do not retry `deferred` edges. With `atomic=true`, skipped operations or dropped inputs return `409` (`OPERATIONS_NOT_APPLIED`) without saving. `mintedBlockIds` maps labels to generated IDs. Lint is advisory; `dryRun=true` validates without saving, auditing, or notifying. The live deployment is unchanged. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "workflows.operations.apply", + "x-oauth-scope": "api:write", "tags": ["Workflows"], "parameters": [ { @@ -536,7 +549,9 @@ "patch": { "operationId": "applyWorkflowVariables", "summary": "Update Workflow Variables", - "description": "Add, edit, and delete a workflow’s variables. Operations are matched by variable `name` and applied in order; a batch that changes nothing answers `200` with `changed: false`. Values are coerced to the declared `type`, and a value that cannot be coerced is stored as supplied. Read the current set from `variables` on `GET /workflows/{workflowId}`.", + "description": "Add, edit, or delete variables by name, applying operations in order. Values are coerced to their declared type when possible; otherwise they are stored as supplied. A batch with no changes returns `200` with `changed: false`. Read current variables with Get Workflow.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "workflows.variables.apply_operations", + "x-oauth-scope": "api:write", "tags": ["Workflows"], "parameters": [ { @@ -625,7 +640,9 @@ "post": { "operationId": "duplicateWorkflow", "summary": "Duplicate Workflow", - "description": "Copy a workflow, including its blocks, edges, subflows, and variables, into the same workspace. Omitting `name` reuses the source name; a collision inside the destination folder is deduplicated rather than refused. A workspace folder tree over 10,000 folders is a `413`.", + "description": "Copy a workflow's graph and variables into the same workspace. Omit `name` to reuse the source name; name collisions in the destination folder are resolved automatically. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "workflows.duplicate", + "x-oauth-scope": "api:write", "tags": ["Workflows"], "parameters": [ { @@ -714,7 +731,9 @@ "post": { "operationId": "restoreWorkflow", "summary": "Restore Workflow", - "description": "Bring an archived workflow back, along with the schedules, webhooks, MCP tools, and chats that were archived with it. A workflow that is not archived answers `409`. A workflow whose folder was archived is restored to the workspace root. A workspace folder tree over 10,000 folders is a `413`.", + "description": "Restore an archived workflow and the schedules, webhooks, MCP tools, and chats archived with it. An active workflow returns `409`. If its folder is archived, the workflow returns to the workspace root. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "workflows.restore", + "x-oauth-scope": "api:write", "tags": ["Workflows"], "parameters": [ { @@ -789,7 +808,9 @@ "post": { "operationId": "moveWorkflows", "summary": "Move Workflows", - "description": "Relocate up to 100 workflows into one folder. Explicitly best-effort: each workflow moves in its own transaction, and one that is absent from the workspace, archived, or locked lands in `failed` while the rest still move. Duplicate ids are collapsed. A workspace folder tree over 10,000 folders is a `413`.", + "description": "Move up to 100 workflows into one folder. Moves succeed or fail independently; missing, archived, or locked workflows appear in `failed`. Duplicate IDs are ignored. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "workflows.bulk.move", + "x-oauth-scope": "api:write", "tags": ["Workflows"], "requestBody": { "required": true, @@ -858,7 +879,9 @@ "get": { "operationId": "getWorkflow", "summary": "Get Workflow", - "description": "Get a workflow with its variables and deployed API-trigger inputs. A workspace folder tree over 10,000 folders is a `413`.", + "description": "Get a workflow with its variables and deployed API-trigger inputs. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "workflows.read", + "x-oauth-scope": "api:read", "tags": ["Workflows"], "parameters": [ { @@ -925,7 +948,9 @@ "patch": { "operationId": "updateWorkflowV2", "summary": "Update Workflow", - "description": "Rename, describe, or move a workflow to a canonical folder path. A workspace folder tree over 10,000 folders is a `413`.", + "description": "Update a workflow's name, description, or folder path. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "workflows.update", + "x-oauth-scope": "api:write", "tags": ["Workflows"], "parameters": [ { @@ -1012,7 +1037,9 @@ "delete": { "operationId": "deleteWorkflowV2", "summary": "Delete Workflow", - "description": "Archive a workflow. Despite the verb, this is not an erasure: the workflow, and the schedules, webhooks, MCP tools, and chats attached to it, are stamped archived and stop running, and `POST /workflows/{workflowId}/restore` brings all of them back. An archived workflow disappears from the default list and is reachable with `scope=archived`. The `deleted` field is retained for shipped clients; `archived` states what actually happened.", + "description": "Archive a workflow and stop its schedules, webhooks, MCP tools, and chats. Use List Workflows with `scope=archived` to find it and Restore Workflow to recover it and its archived resources. Both `deleted` and `archived` acknowledge archival.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "workflows.delete", + "x-oauth-scope": "api:write", "tags": ["Workflows"], "parameters": [ { @@ -1081,7 +1108,9 @@ "get": { "operationId": "listWorkflowVersionsV2", "summary": "List Workflow Versions", - "description": "List immutable deployment versions of a workflow, newest first.", + "description": "List immutable deployment versions of a workflow, newest first.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "workflows.versions.list", + "x-oauth-scope": "api:read", "tags": ["Workflows"], "parameters": [ { @@ -1171,7 +1200,9 @@ "get": { "operationId": "getWorkflowVersionV2", "summary": "Get Workflow Version", - "description": "Get an immutable deployment version and its pinned workflow graph snapshot.", + "description": "Get an immutable deployment version and its pinned workflow graph snapshot.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "workflows.versions.read", + "x-oauth-scope": "api:read", "tags": ["Workflows"], "parameters": [ { @@ -1247,7 +1278,9 @@ "patch": { "operationId": "updateWorkflowVersionV2", "summary": "Update Workflow Version", - "description": "Relabel a deployment version. Merge-patch shaped: an omitted key is unchanged and `description: null` clears the release note. Metadata only — the pinned graph is immutable, and this never changes which version is live. Promote a version with `POST /workflows/{workflowId}/versions/{version}/activate`.", + "description": "Update a deployment version's name or release note. Omitted fields remain unchanged; `description: null` clears the note. The graph and live version remain unchanged. Use Activate Workflow Version to make this version live.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "workflows.versions.update", + "x-oauth-scope": "api:write", "tags": ["Workflows"], "parameters": [ { @@ -1342,7 +1375,9 @@ "post": { "operationId": "activateWorkflowVersion", "summary": "Activate Workflow Version", - "description": "Promote an existing deployment version to live. Activation is asynchronous; inspect `isDeployed` and `latestDeploymentAttempt` for current state. Unlike `rollback`, the target is named by the path and the workflow need not already be deployed. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Asynchronously activate a specific deployment version, including when the workflow is not currently deployed. The draft remains unchanged. Read Get Workflow Deployment for `isDeployed` and `latestDeploymentAttempt`. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "workflows.versions.activate", + "x-oauth-scope": "api:write", "tags": ["Workflows"], "parameters": [ { @@ -1443,7 +1478,9 @@ "post": { "operationId": "revertWorkflowVersion", "summary": "Revert Workflow To Version", - "description": "Overwrite the editable draft with the graph pinned by a deployment version, discarding every unsaved edit. This is the most destructive operation in the deployment family and it does **not** change what is live — to move production, use `activate` or `rollback`, both of which leave the draft alone. Pass `active` as the version to discard draft edits and return to the live graph. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Replace the editable draft with a deployment version, discarding current draft edits. Use `active` for the live version. The live deployment remains unchanged; Activate Workflow Version or Rollback Workflow changes it. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "workflows.versions.revert", + "x-oauth-scope": "api:write", "tags": ["Workflows"], "parameters": [ { @@ -1552,7 +1589,9 @@ "get": { "operationId": "getWorkflowDeployment", "summary": "Get Workflow Deployment", - "description": "Read the current deployment state of a workflow: whether a version is live, when it went live, the most recent deployment attempt with its readiness and failure payload, and whether the editable draft has since diverged from the live version. This is the only operation that publishes `needsRedeployment` and `isPublicApi`.\n\n`isPublicApi` is the security-relevant one: while it is `true` the deployed workflow executes without an API key, so anyone holding the execution URL can run it — and consume the workspace’s billed usage — anonymously. It is set through `PATCH /workflows/{workflowId}/deployment`, and this read is the only way to audit whether it is on.\n\nNot to be confused with `/workflows/{workflowId}/deployments/chat`, which is the hosted chat the workflow is published as. This path governs whether the workflow is executable at all; that one governs one surface it is served on. A workflow can be deployed with no chat, and removing its chat leaves it deployed and executable.", + "description": "Get the live version, latest deployment attempt, readiness, draft changes (`needsRedeployment`), and public API access. With `isPublicApi: true`, anyone with the execution URL can run the workflow and consume billed usage without an API key. Hosted chat is managed separately.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "workflows.read", + "x-oauth-scope": "api:read", "tags": ["Workflows"], "parameters": [ { @@ -1616,7 +1655,9 @@ "patch": { "operationId": "updateWorkflowPublicApi", "summary": "Update Workflow Public API Access", - "description": "Enable or disable unauthenticated public execution of the deployed workflow. While enabled, anyone holding the execution URL can run the workflow without an API key. An organization that forbids public sharing refuses this with `403` and `PUBLIC_SHARING_NOT_ALLOWED`. Not to be confused with `/workflows/{workflowId}/deployments/chat`, which is the hosted chat the workflow is published as. This path governs whether the workflow is executable at all; that one governs one surface it is served on. A workflow can be deployed with no chat, and removing its chat leaves it deployed and executable. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Enable or disable unauthenticated execution of the deployed workflow. Enabling allows anyone with the execution URL to consume billed usage. Organization sharing restrictions return `403` with `PUBLIC_SHARING_NOT_ALLOWED`. Hosted chat is managed separately. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "workflows.public_api.update", + "x-oauth-scope": "api:write", "tags": ["Workflows"], "parameters": [ { @@ -1702,7 +1743,9 @@ "post": { "operationId": "deployWorkflow", "summary": "Deploy Workflow", - "description": "Create and asynchronously activate a deployment version. Not idempotent: every call mints a new version, so a retry after a timeout creates a second one. A deployment that would conflict with an existing webhook path is a `409`. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Create and asynchronously activate a deployment version. Every call creates a new version; retrying after a timeout can create a duplicate. Read Get Workflow Deployment to check activation. A conflicting webhook path returns `409`. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "workflows.deploy", + "x-oauth-scope": "api:write", "tags": ["Workflows"], "parameters": [ { @@ -1789,7 +1832,9 @@ "delete": { "operationId": "undeployWorkflow", "summary": "Undeploy Workflow", - "description": "Deactivate the currently serving workflow version. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Deactivate the currently serving workflow version. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "workflows.undeploy", + "x-oauth-scope": "api:write", "tags": ["Workflows"], "parameters": [ { @@ -1858,7 +1903,9 @@ "post": { "operationId": "rollbackWorkflow", "summary": "Rollback Workflow", - "description": "Asynchronously reactivate a previous deployment version, selecting the preceding active version when no version is supplied. Use this to step back from the currently live version; to make a specific version live by naming it in the path — including when the workflow is not currently deployed — use `POST /workflows/{workflowId}/versions/{version}/activate`. Neither touches the draft. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Asynchronously activate a previous deployment version, defaulting to the preceding active version. Requires a deployed workflow and leaves the draft unchanged. Use Activate Workflow Version to select a version when the workflow is undeployed. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "workflows.versions.activate", + "x-oauth-scope": "api:write", "tags": ["Workflows"], "parameters": [ { @@ -1947,7 +1994,9 @@ "get": { "operationId": "exportWorkflow", "summary": "Export Workflow", - "description": "Export a portable, secret-sanitized workflow. Workspace-scoped bindings must be selected again after import. Exporting records an audit event, so it is not a safe read. A `HEAD` skips the effect but is authorized exactly as the `GET` is, so it answers `400`, `401`, `403`, or `404` wherever the `GET` would and an empty `200` otherwise. Skipping the effect means skipping the read that produces the payload, so that `200` carries none of the response headers documented below — it answers whether the `GET` would be allowed, not what the `GET` would return. A workspace folder tree over 10,000 folders is a `413`.", + "description": "Export a portable, secret-sanitized workflow; workspace-scoped bindings must be selected again after import. Exporting records an audit event. `HEAD` checks access with the same authorization as `GET` but skips side effects, returning an empty `200` without payload headers on success. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "workflows.export", + "x-oauth-scope": "api:read", "tags": ["Workflows"], "parameters": [ { @@ -2016,7 +2065,9 @@ "post": { "operationId": "importWorkflow", "summary": "Import Workflow", - "description": "Create a workflow from a portable export object, bare state, or JSON string. A workspace folder tree over 10,000 folders is a `413`.", + "description": "Create a workflow from a portable export object, bare state, or JSON string. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "workflows.import", + "x-oauth-scope": "api:write", "tags": ["Workflows"], "requestBody": { "required": true, @@ -2091,7 +2142,9 @@ "get": { "operationId": "listChatDeployments", "summary": "List Chat Deployments", - "description": "List the workflows a workspace has published as hosted chats. Each entry carries the public `url` a visitor uses — there is no chat subdomain, the identifier is a path segment.\n\nThis is the only chat path not addressed under a workflow, and deliberately so: every chat is a singleton of the workflow it publishes, but \"what does this workspace serve\" is a question no per-workflow path can answer. Filter by `workflowId` to resolve one workflow's chat without holding its id.\n\nEntries are deliberately narrower than the singleton read: `allowedEmails`, `hasPassword`, and `customizations` are available only from `GET /api/v2/workflows/{workflowId}/deployments/chat`, which requires workspace `admin`. That is what keeps this list callable at workspace `read` and by a workspace API key. A stored password is never returned by either.", + "description": "List hosted chats and their public URLs with cursor pagination. Filter by `workflowId` for one workflow's chat. The list requires workspace read access; Get Workflow Chat Deployment requires admin access and includes visitor access settings and customizations. Passwords are never returned.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "chat_deployments.list", + "x-oauth-scope": "api:read", "tags": ["Workflows"], "parameters": [ { @@ -2226,7 +2279,9 @@ "get": { "operationId": "getWorkflowChatDeployment", "summary": "Get Workflow Chat Deployment", - "description": "Read the hosted chat a workflow is published as. Answers `404` when the workflow publishes no chat. Not to be confused with `/workflows/{workflowId}/deployment` (singular), which is the workflow's own API deployment — its live version and whether the draft has drifted. That path governs whether the workflow is executable at all; this one governs the hosted chat it is served on. The chat is a singleton of its workflow, so it has no id of its own in any path and no separate create verb: `PUT` is create-or-replace and is the only write. The stored password is never returned — `hasPassword` reports only whether one is set. This carries the visitor gate — `authType`, `hasPassword`, and the `allowedEmails` allow-list — so it requires workspace `admin`, unlike the workspace-wide list. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Get a workflow's hosted chat and visitor access settings. Requires workspace admin access; a missing chat returns `404`. Passwords are never returned; `hasPassword` indicates whether one is set. Hosted chat and workflow API deployment are managed separately. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "chat_deployments.read", + "x-oauth-scope": "api:read", "tags": ["Workflows"], "parameters": [ { @@ -2290,7 +2345,9 @@ "put": { "operationId": "replaceWorkflowChatDeployment", "summary": "Create or Replace Workflow Chat Deployment", - "description": "Publish a workflow as a hosted chat, or replace the chat it already publishes. Not to be confused with `/workflows/{workflowId}/deployment` (singular), which is the workflow's own API deployment — its live version and whether the draft has drifted. That path governs whether the workflow is executable at all; this one governs the hosted chat it is served on. The chat is a singleton of its workflow, so it has no id of its own in any path and no separate create verb: `PUT` is create-or-replace and is the only write.\n\n**Replace, not merge.** The chat ends up as exactly what the body describes: an omitted optional field takes its platform default rather than whatever the previous chat carried, so sending the same body twice leaves the same result. `password` is therefore required whenever `authType` is `\"password\"` and rejected otherwise — it is write-only and never readable back, so carrying one over implicitly is the one place a replace would quietly stop meaning replace. `allowedEmails` follows the same rule: required and non-empty for `\"email\"` and `\"sso\"`, rejected for the modes that admit no allow-list. `customizations` is the one documented exception: it merges per field, so an omitted `imageUrl` keeps the stored one rather than clearing it, and customization keys this surface does not declare do not survive the write. That behaviour is shared with the in-app editor and the Copilot deploy tool, which both send partial objects.\n\nThis also deploys the workflow, because a chat serves the live version: a draft that has drifted is republished as part of the call. Two conditions answer `409` — an `identifier` another live chat already holds, and a workflow deployment attempt still preparing, which the caller can retry once it becomes active. `authType: \"public\"` leaves the chat open to anyone holding the URL. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Create or replace a workflow's hosted chat and deploy its draft. Omitted fields reset to defaults except per-field customizations. Password authentication requires `password`; email or SSO requires non-empty `allowedEmails`. Public authentication allows anyone with the chat URL to use it. A duplicate identifier or pending deployment returns `409`. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "chat_deployments.replace", + "x-oauth-scope": "api:write", "tags": ["Workflows"], "parameters": [ { @@ -2377,7 +2434,9 @@ "delete": { "operationId": "deleteWorkflowChatDeployment", "summary": "Delete Workflow Chat Deployment", - "description": "Stop serving a workflow's hosted chat. Its URL stops answering and the identifier becomes free again. The workflow's own deployment is untouched and stays executable through the workflow API — to undeploy that, use `DELETE /workflows/{workflowId}/deploy`. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Remove a workflow's hosted chat and release its URL identifier. The workflow API deployment remains active; use Undeploy Workflow to stop it. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "chat_deployments.delete", + "x-oauth-scope": "api:write", "tags": ["Workflows"], "parameters": [ { @@ -2443,12 +2502,17 @@ "post": { "operationId": "executeWorkflowV2", "summary": "Execute Workflow", - "description": "Execute the active deployment by default, or select manual execution of the current saved workflow state with `run.source: \"manual\"`. Manual runs require a personal API key with current write access and support synchronous or Server-Sent Event execution only; workspace keys, anonymous public access, and async manual runs are rejected. A manual run can enter through one runnable trigger (including external integration/webhook triggers) or resume at a named block from the exact same-workflow run identified by `sourceRunId`; the server loads that run's persisted snapshot, which is never accepted from the request. Omit a trigger block id only when the saved workflow has exactly one runnable trigger. Public deployed workflows permit anonymous synchronous and streaming execution, while asynchronous deployed execution requires an API key. A synchronous run that exceeds its execution timeout returns HTTP 200 with `status: \"failed\"` and `error.code: \"TIMEOUT\"` rather than an HTTP error, so branch on `status`. Each option carries the modes it requires and the modes that reject it; a violated combination is a 400.", + "description": "Execute a deployment or use `run.source: \"manual\"` for the draft. Manual runs require personal or OAuth write access and reject async. Public deployments allow anonymous sync or streaming. Request `application/x-ndjson` for heartbeats and the final result. Timeouts return `200` with failed status and `TIMEOUT`. Supply `X-Run-Id` to prevent duplicate execution; reuse returns `409`, never a replay. Input descriptions specify compatible modes; invalid combinations return `400`.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "workflows.execute", + "x-oauth-scope": "api:write", "tags": ["Workflows"], "security": [ { "apiKey": [] }, + { + "oauthBearer": [] + }, {} ], "parameters": [ @@ -2468,9 +2532,9 @@ "name": "x-run-id", "in": "header", "required": false, - "description": "Caller-supplied run identifier, available only to API-key callers. A one-shot uniqueness claim, NOT an idempotency key: reusing a value fails with `409` and `error.details.code: \"RUN_ID_CONFLICT\"` rather than replaying the original result. To retry safely, send a fresh value per attempt, or omit the header and let the server allocate one.", + "description": "Run ID for API-key or OAuth callers; ignored for anonymous requests. Reuse it after an uncertain response: a claimed ID returns `409` with `RUN_ID_CONFLICT`, without replaying results. Check Get Workflow Run, but `404` can persist while the ID remains claimed and does not establish whether execution started. Do not automatically restart with a fresh or omitted ID; either can start another run.", "schema": { - "description": "Caller-supplied run identifier, available only to API-key callers. A one-shot uniqueness claim, NOT an idempotency key: reusing a value fails with `409` and `error.details.code: \"RUN_ID_CONFLICT\"` rather than replaying the original result. To retry safely, send a fresh value per attempt, or omit the header and let the server allocate one.", + "description": "Run ID for API-key or OAuth callers; ignored for anonymous requests. Reuse it after an uncertain response: a claimed ID returns `409` with `RUN_ID_CONFLICT`, without replaying results. Check Get Workflow Run, but `404` can persist while the ID remains claimed and does not establish whether execution started. Do not automatically restart with a fresh or omitted ID; either can start another run.", "type": "string", "minLength": 1, "maxLength": 128, @@ -2491,7 +2555,7 @@ ], "requestBody": { "required": true, - "description": "Input, workflow-state selection, and execution-mode options. Each option carries the modes it requires and the modes that reject it; a violated combination is a 400.", + "description": "Input, workflow-state selection, and execution-mode options. Input descriptions specify compatible modes; invalid combinations return `400`.", "content": { "application/json": { "schema": { @@ -2502,7 +2566,7 @@ }, "responses": { "200": { - "description": "A synchronous run result or Server-Sent Event stream.", + "description": "A synchronous run result, heartbeat-delimited NDJSON result stream, or Server-Sent Event stream.", "headers": { "X-Run-Id": { "$ref": "#/components/headers/X-Run-Id" @@ -2523,6 +2587,11 @@ "$ref": "#/components/schemas/ExecuteWorkflowSyncResponse" } }, + "application/x-ndjson": { + "schema": { + "type": "string" + } + }, "text/event-stream": { "schema": { "type": "string" @@ -2597,7 +2666,9 @@ "get": { "operationId": "listWorkflowRunsV2", "summary": "List Workflow Runs", - "description": "List recorded runs of a workflow with filtering and opaque cursor pagination. Runs are hard-deleted once they pass the payer's log retention window, so an older run is simply absent rather than reported as removed. The window is 30 days from run start on the free plan, unbounded on Pro and Team, and set per organization on Enterprise with an optional per-workspace override.", + "description": "List recorded runs of a workflow with filtering and opaque cursor pagination. Expired runs are permanently deleted. Retention is 30 days from run start on Free, unlimited on Pro and Team, and configured per organization on Enterprise with workspace overrides.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "workflows.runs.list", + "x-oauth-scope": "api:read", "tags": ["Workflow Runs"], "parameters": [ { @@ -2745,7 +2816,9 @@ "get": { "operationId": "getWorkflowRunV2", "summary": "Get Workflow Run", - "description": "Get current workflow run state, optionally including final and block outputs. With `includeOutput`, `files` lists the files the run produced, each with a `downloadPath`; add `includeFileBase64` to inline their bytes, which answers `413` naming the download path when a single file, or the run's inlined total, exceeds the 16 MiB ceiling. Because inlining reads object storage, this `GET` is not a safe read. A `HEAD` skips the effect but is authorized exactly as the `GET` is, so it answers `400`, `401`, `403`, or `404` wherever the `GET` would and an empty `200` otherwise. Skipping the effect means skipping the read that produces the payload, so that `200` carries none of the response headers documented below — it answers whether the `GET` would be allowed, not what the `GET` would return.", + "description": "Get current run state with optional final and block outputs. With `includeOutput`, `files` includes download paths; `includeFileBase64` inlines file bytes and returns `413` with the download path when one file or the total exceeds 16 MiB. `HEAD` checks access with the same authorization as `GET` but skips side effects, returning an empty `200` without payload headers on success.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "workflows.runs.read", + "x-oauth-scope": "api:read", "tags": ["Workflow Runs"], "parameters": [ { @@ -2872,7 +2945,9 @@ "get": { "operationId": "downloadWorkflowRunFileV2", "summary": "Download Workflow Run File", - "description": "Download one file a run produced. The run resource reports the files a run emitted; address one of them by its `id` here. Run output carries `/api/files/serve/...` URLs that reject API keys, so this is the byte path out of a run for an API-key caller. Execution objects are not retained indefinitely, so a `404` for a file an older run produced is expected rather than a fault. Runs are hard-deleted once they pass the payer's log retention window, so an older run is simply absent rather than reported as removed. The window is 30 days from run start on the free plan, unbounded on Pro and Team, and set per organization on Enterprise with an optional per-workspace override. Downloading records an audit event, so it is not a safe read. A `HEAD` skips the effect but is authorized exactly as the `GET` is, so it answers `400`, `401`, `403`, or `404` wherever the `GET` would and an empty `200` otherwise. Skipping the effect means skipping the read that produces the payload, so that `200` carries none of the response headers documented below — it answers whether the `GET` would be allowed, not what the `GET` would return. In particular a `HEAD` does not report `Content-Length`, so it cannot be used to size a download in advance; read the size from the file resource instead.", + "description": "Download one run-produced file by ID. Downloads record an audit event. Expired runs are permanently deleted. Retention is 30 days from run start on Free, unlimited on Pro and Team, and configured per organization on Enterprise with workspace overrides. `HEAD` checks access with the same authorization as `GET` but skips side effects, returning an empty `200` without payload headers on success. `HEAD` omits `Content-Length`; use file metadata to size downloads.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "workflows.download_run_file", + "x-oauth-scope": "api:read", "tags": ["Workflow Runs"], "parameters": [ { @@ -2976,7 +3051,9 @@ "post": { "operationId": "resumeWorkflowRunV2", "summary": "Resume Workflow Run", - "description": "Resume one human-in-the-loop pause context. The resumed attempt receives a new run identifier and may complete synchronously or return a queue receipt.", + "description": "Resume one human-in-the-loop pause. The resumed attempt receives a new run ID and returns either a synchronous result or a queue receipt.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "workflows.runs.resume", + "x-oauth-scope": "api:write", "tags": ["Workflow Runs"], "parameters": [ { @@ -3105,7 +3182,9 @@ "post": { "operationId": "cancelRunV2", "summary": "Cancel Workflow Run", - "description": "Request cancellation of a running, queued, or paused workflow run. Cancelling a run already in a terminal state succeeds with no effect. A run produced by a table workflow group is a `409` when its cell can no longer accept the cancellation.", + "description": "Request cancellation of a running, queued, or paused workflow run. Terminal runs return successfully without changes. A table workflow-group run returns `409` if its cell can no longer accept cancellation.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "workflows.runs.cancel", + "x-oauth-scope": "api:write", "tags": ["Workflow Runs"], "parameters": [ { @@ -3187,7 +3266,9 @@ "get": { "operationId": "listWorkflowsFolders", "summary": "List Workflow Folders", - "description": "List canonical workflow folders in a workspace. The bounded set is returned in one page; `nextCursor` is always null. A workspace folder tree over 10,000 folders is a `413`.", + "description": "List workflow folders in a workspace. Returns the complete set in one page; `nextCursor` is always null. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "workflows.folders.list", + "x-oauth-scope": "api:read", "tags": ["Workflows"], "parameters": [ { @@ -3206,9 +3287,9 @@ "name": "parentPath", "in": "query", "required": false, - "description": "Restrict results to direct children of this parent path. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", + "description": "Restrict results to direct children of this parent path. Unknown folder paths contribute no matches.", "schema": { - "description": "Restrict results to direct children of this parent path. A path that names no folder narrows the result to nothing, so the response is an empty page rather than an error.", + "description": "Restrict results to direct children of this parent path. Unknown folder paths contribute no matches.", "$ref": "#/components/schemas/FolderPathInput" } }, @@ -3300,7 +3381,9 @@ "post": { "operationId": "createWorkflowsFolder", "summary": "Create Workflow Folder", - "description": "Create a canonical workflow folder in a workspace. A workspace folder tree over 10,000 folders is a `413`.", + "description": "Create a workflow folder in a workspace. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "workflows.folders.create", + "x-oauth-scope": "api:write", "tags": ["Workflows"], "requestBody": { "required": true, @@ -3373,7 +3456,9 @@ "patch": { "operationId": "relocateWorkflowsFolder", "summary": "Rename or Move Workflow Folder", - "description": "Rename or move a workflow folder and its descendants to a canonical path. A workspace folder tree over 10,000 folders is a `413`.", + "description": "Rename or move a workflow folder and update all descendant paths. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "workflows.folders.relocate", + "x-oauth-scope": "api:write", "tags": ["Workflows"], "requestBody": { "required": true, @@ -3446,7 +3531,9 @@ "delete": { "operationId": "deleteWorkflowsFolder", "summary": "Delete Workflow Folder", - "description": "Delete a workflow folder, optionally including its descendants and workflows.", + "description": "Archive an empty workflow folder, or set `recursive=true` to archive its subfolders and workflows. Use Restore Workflow to recover workflows.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "workflows.folders.delete", + "x-oauth-scope": "api:write", "tags": ["Workflows"], "parameters": [ { @@ -3560,6 +3647,12 @@ "in": "header", "name": "X-API-Key", "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description." + }, + "oauthBearer": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "OAuth 2.0 access token", + "description": "A Sim OAuth access token obtained by a registered client through the authorization-code flow. Each operation declares its required scope: api:read permits reads and searches; api:write also permits changes and execution and implies api:read. Scope requirements follow the application operation, independent of HTTP method or workspace role." } }, "headers": { @@ -3656,7 +3749,7 @@ } }, "Unauthorized": { - "description": "The API key is missing or invalid.", + "description": "The API credential is missing or invalid.", "content": { "application/json": { "schema": { @@ -3665,7 +3758,7 @@ "example": { "error": { "code": "UNAUTHORIZED", - "message": "API key required" + "message": "Authentication required" } } } @@ -3893,6 +3986,49 @@ } }, "schemas": { + "V2ActionableForbiddenDetails": { + "type": "object", + "properties": { + "code": { + "$ref": "#/components/schemas/V2ForbiddenDetailCode" + } + }, + "required": ["code"], + "additionalProperties": { + "description": "Additional context for this refusal." + }, + "title": "Actionable forbidden details", + "description": "Machine-readable cause and optional context for an actionable `403` response." + }, + "V2ForbiddenDetailCode": { + "type": "string", + "enum": [ + "INSUFFICIENT_WORKSPACE_ROLE", + "PERSONAL_API_KEYS_DISABLED", + "WORKSPACE_KEY_OPERATION_NOT_PERMITTED", + "PRINCIPAL_KIND_NOT_PERMITTED", + "ORGANIZATION_MEMBERSHIP_REQUIRED", + "ORGANIZATION_ADMIN_REQUIRED", + "ENTERPRISE_PLAN_REQUIRED", + "ORGANIZATION_PLAN_REQUIRED", + "AUDIT_LOGS_DISABLED", + "SKILL_EDITOR_ACCESS_REQUIRED", + "SECRET_ADMIN_ACCESS_REQUIRED", + "WORKSPACE_RESOURCE_LIMIT_REACHED", + "PUBLIC_SHARING_NOT_ALLOWED", + "CREDENTIAL_ADMIN_ACCESS_REQUIRED", + "MCP_SERVER_URL_NOT_ALLOWED", + "WORKSPACE_PLAN_CAPABILITY_REQUIRED", + "CHAT_AUTH_MODE_NOT_PERMITTED", + "CONNECTOR_MANAGED_RESOURCE_READ_ONLY", + "PERMISSION_GROUP_CAPABILITY_BLOCKED", + "INTEGRATION_NOT_ALLOWED", + "INSUFFICIENT_SCOPE", + "SCIM_MANAGED_MEMBERSHIP" + ], + "title": "Forbidden detail code", + "description": "Stable cause code for an actionable `403` response." + }, "V2Error": { "type": "object", "properties": { @@ -3908,7 +4044,15 @@ "description": "Human-readable explanation of the error." }, "details": { - "description": "Structured error details. On a `403` whose cause a caller can act on, this carries a `code` from a closed set:\n- `INSUFFICIENT_WORKSPACE_ROLE` — The caller has access to the workspace but its role is below the one this operation requires.\n- `PERSONAL_API_KEYS_DISABLED` — The workspace's organization does not allow personal API keys. Use a workspace API key.\n- `WORKSPACE_KEY_OPERATION_NOT_PERMITTED` — This operation is not available to a workspace-scoped API key. Use a personal API key.\n- `PRINCIPAL_KIND_NOT_PERMITTED` — This operation does not accept the caller’s kind of API key.\n- `ORGANIZATION_MEMBERSHIP_REQUIRED` — The caller is not a member of the organization it named.\n- `ORGANIZATION_ADMIN_REQUIRED` — The caller is a member of the organization but not an admin or owner.\n- `ENTERPRISE_PLAN_REQUIRED` — The organization has no active enterprise subscription.\n- `ORGANIZATION_PLAN_REQUIRED` — The organization has no active organization subscription (Pro for Teams, Max for Teams, or Enterprise).\n- `AUDIT_LOGS_DISABLED` — Audit logging is not enabled for this deployment.\n- `SKILL_EDITOR_ACCESS_REQUIRED` — The caller can write in the workspace but is not an editor of this skill.\n- `SECRET_ADMIN_ACCESS_REQUIRED` — The caller can write in the workspace but is not an admin of this secret. Ask a workspace admin, or someone holding admin on the secret, to grant access or set the value.\n- `WORKSPACE_RESOURCE_LIMIT_REACHED` — The workspace already holds the maximum number of resources of this kind. Delete one, or contact Sim to raise the limit; the message names the ceiling.\n- `PUBLIC_SHARING_NOT_ALLOWED` — The workspace's organization does not permit sharing this resource publicly. An organization admin controls the policy.\n- `CREDENTIAL_ADMIN_ACCESS_REQUIRED` — The caller can reach the workspace but cannot administer this credential.\n- `MCP_SERVER_URL_NOT_ALLOWED` — The supplied MCP server URL is outside the allowed domains or resolves to an internal address.\n- `WORKSPACE_PLAN_CAPABILITY_REQUIRED` — The workspace's plan does not include a capability this request depends on. The message names the capability; upgrading the workspace's plan is the remedy.\n- `CHAT_AUTH_MODE_NOT_PERMITTED` — The workspace's permission group does not allow the chat authentication mode the request selected. A mode already saved on the deployment may still be re-saved; changing to a disallowed one cannot.\n- `CONNECTOR_MANAGED_RESOURCE_READ_ONLY` — This resource is managed by a knowledge base connector and cannot be edited directly. Change it at the source and re-sync, or exclude the document from the connector.\n- `PERMISSION_GROUP_CAPABILITY_BLOCKED` — The caller's permission group does not allow this capability. The message names it; an organization admin controls the group.\n- `INTEGRATION_NOT_ALLOWED` — The integration this request names is outside the workspace's allowed set. An organization admin controls the permission group's integration allowlist, and a self-hosted deployment can narrow it further with ALLOWED_INTEGRATIONS." + "description": "Structured error context whose keys depend on the error. Actionable `403` responses use the `V2ActionableForbiddenDetails` shape; validation failures may return issue arrays instead.", + "anyOf": [ + { + "$ref": "#/components/schemas/V2ActionableForbiddenDetails" + }, + { + "description": "Other structured context defined by the specific error." + } + ] } }, "required": ["code", "message"], @@ -3995,7 +4139,7 @@ "type": "integer", "minimum": 0, "maximum": 9007199254740991, - "description": "Runs that finished successfully. Failed, cancelled, and paused runs are not counted, and the counter is never reduced when a run ages out of log retention — so it does not match the size of `GET /api/v2/workflows/{workflowId}/runs`, in either direction." + "description": "Lifetime count of successful runs, excluding failed, canceled, and paused runs. Log retention does not reduce this count; it may differ from the number returned by List Workflow Runs." }, "lastRunAt": { "anyOf": [ @@ -4167,7 +4311,7 @@ "type": "integer", "minimum": 0, "maximum": 9007199254740991, - "description": "Runs that finished successfully. Failed, cancelled, and paused runs are not counted, and the counter is never reduced when a run ages out of log retention — so it does not match the size of `GET /api/v2/workflows/{workflowId}/runs`, in either direction." + "description": "Lifetime count of successful runs, excluding failed, canceled, and paused runs. Log retention does not reduce this count; it may differ from the number returned by List Workflow Runs." }, "lastRunAt": { "anyOf": [ @@ -5852,7 +5996,7 @@ "items": { "$ref": "#/components/schemas/WorkflowSkippedItem" }, - "description": "Forward-referencing edges the engine recorded rather than applied. These are NOT failures: the engine wires each one as soon as its target block exists, in this batch or a later one. Do not re-issue them." + "description": "Edges waiting for target blocks. They apply automatically when their targets exist, in this batch or a later one. Do not resubmit them." }, "inputValidationErrors": { "type": "array", @@ -5870,7 +6014,7 @@ "type": "string", "description": "The id the block was actually given." }, - "description": "The id each newly created block was actually given, keyed by the `block_id` you asked for, and present only for the ones that differ. A `block_id` on an `add` or `insert_into_subflow` that is not already a UUID is replaced with a minted one, so this is how you learn what to reference afterwards. Within a single batch you can keep using your own ids — references between operations are remapped for you — but a later request must use the minted id, so send your own UUIDs when you want an id you chose to survive." + "description": "Minted block ids keyed by requested `block_id`, present only when they differ. References within this batch are remapped automatically; later requests must use the minted id. Supply a UUID when the requested id must survive unchanged." }, "lint": { "$ref": "#/components/schemas/WorkflowLintReport" @@ -6004,7 +6148,7 @@ "additionalProperties": { "description": "One block-specific input or connection descriptor." }, - "description": "Block type and name, plus any block-specific configuration. Beyond `type` and `name` the accepted keys are `inputs`, `connections`, `retry`, `triggerMode`, and `advancedMode`. `inputs` carries the block's own configuration keyed by sub-block id, for example `inputs: { model: \"gpt-4o\", systemPrompt: \"...\" }` — never wrapped in `subBlocks`. Block-level settings sit beside `inputs`, never inside it: `retry`, `triggerMode`, `advancedMode`. `connections` is keyed by source handle and each value is a target block id, `{ block, handle }`, or an array of either; `success` is accepted as an alias for the `source` handle." + "description": "Block `type`, `name`, and optional `inputs`, `connections`, `retry`, `triggerMode`, or `advancedMode`. `inputs` maps sub-block ids directly to values, never through `subBlocks`. Keep `retry`, `triggerMode`, and `advancedMode` beside `inputs`. `connections` maps source handles to target ids, `{ block, handle }`, or arrays; `success` aliases `source`." } }, "required": ["operation_type", "block_id", "params"], @@ -6069,7 +6213,7 @@ } } ], - "description": "Fields to change on the target block. Send only what changes. Accepted keys: `inputs`, `name`, `connections`, `removeEdges`, `nestedNodes`, `retry`, `triggerMode`, `advancedMode`. `inputs` carries the block's own configuration keyed by sub-block id, for example `inputs: { model: \"gpt-4o\", systemPrompt: \"...\" }` — never wrapped in `subBlocks`. Block-level settings sit beside `inputs`, never inside it: `retry`, `triggerMode`, `advancedMode`. `connections` is keyed by source handle and each value is a target block id, `{ block, handle }`, or an array of either; `success` is accepted as an alias for the `source` handle. Re-sending `connections` replaces that block's outgoing edges, so use `removeEdges` — `[{ targetBlockId, sourceHandle? }]`, `sourceHandle` defaulting to `source` — to drop one edge without restating the rest." + "description": "Patch only supplied fields: `inputs`, `name`, `connections`, `removeEdges`, `nestedNodes`, `retry`, `triggerMode`, and `advancedMode`. `inputs` maps sub-block ids directly to values, never through `subBlocks`. Keep `retry`, `triggerMode`, and `advancedMode` beside `inputs`. `connections` maps source handles to target ids, `{ block, handle }`, or arrays; `success` aliases `source`. Re-sending `connections` replaces outgoing edges; use `removeEdges` to delete selected edges." } }, "required": ["operation_type", "block_id", "params"], @@ -6154,7 +6298,7 @@ "additionalProperties": { "description": "One block-specific input or connection descriptor." }, - "description": "Container, block type and name, plus any block-specific configuration. Takes the same keys as an `add`: `inputs`, `connections`, `retry`, `triggerMode`, `advancedMode`. `inputs` carries the block's own configuration keyed by sub-block id, for example `inputs: { model: \"gpt-4o\", systemPrompt: \"...\" }` — never wrapped in `subBlocks`. Block-level settings sit beside `inputs`, never inside it: `retry`, `triggerMode`, `advancedMode`. `connections` is keyed by source handle and each value is a target block id, `{ block, handle }`, or an array of either; `success` is accepted as an alias for the `source` handle." + "description": "Container, block `type`, `name`, and the same optional fields as `add`. `inputs` maps sub-block ids directly to values, never through `subBlocks`. Keep `retry`, `triggerMode`, and `advancedMode` beside `inputs`. `connections` maps source handles to target ids, `{ block, handle }`, or arrays; `success` aliases `source`." } }, "required": ["operation_type", "block_id", "params"], @@ -6234,7 +6378,7 @@ "description": "Catalog block id, such as `cloudwatch` or `slack`. Use the block id, never an underlying tool id." }, "operation": { - "description": "Operation id from `GET /api/v2/blocks/{blockId}`. Required when the block exposes multiple operations; it may differ from the underlying tool id.", + "description": "Operation ID from Get Block. Required when the block exposes multiple operations; it may differ from the tool ID.", "type": "string", "minLength": 1, "maxLength": 255 @@ -6284,7 +6428,7 @@ "type": "string", "minLength": 1, "maxLength": 255, - "description": "Custom tool id returned by `GET /api/v2/custom-tools`." + "description": "Custom tool ID from List Custom Tools." }, "usageControl": { "type": "string", @@ -6367,7 +6511,7 @@ } ], "title": "Agent custom tool", - "description": "A workspace custom tool. Reference `customToolId` is the preferred shape; the inline declaration is retained for legacy workflow round trips.", + "description": "A workspace custom tool. Prefer `customToolId`; inline declarations are also accepted.", "examples": [ { "type": "custom-tool", @@ -6936,7 +7080,7 @@ "type": "integer", "minimum": 0, "maximum": 9007199254740991, - "description": "Runs that finished successfully. Failed, cancelled, and paused runs are not counted, and the counter is never reduced when a run ages out of log retention — so it does not match the size of `GET /api/v2/workflows/{workflowId}/runs`, in either direction." + "description": "Lifetime count of successful runs, excluding failed, canceled, and paused runs. Log retention does not reduce this count; it may differ from the number returned by List Workflow Runs." }, "lastRunAt": { "anyOf": [ @@ -7107,7 +7251,7 @@ "archived": { "type": "boolean", "const": true, - "description": "The workflow was archived, not erased. Its schedules, webhooks, MCP tools, and chats were archived with it, and `POST /workflows/{workflowId}/restore` brings all of them back." + "description": "Whether the workflow was archived. Restore Workflow recovers it and the schedules, webhooks, MCP tools, and chats archived with it." } }, "required": ["id", "deleted", "archived"], @@ -7305,7 +7449,7 @@ "format": "date-time" }, "state": { - "description": "Deployed workflow graph snapshot pinned by this version, with credential-bearing values redacted to null: `oauth-input`, `password: true`, table sub-block values, sensitive nested tool parameters, and any parameter without authoritative codec metadata.", + "description": "Workflow graph saved with this deployment version. Sensitive values are redacted to null.", "$ref": "#/components/schemas/DeployedWorkflowState" } }, @@ -7904,7 +8048,7 @@ }, "isPublicApi": { "type": "boolean", - "description": "Whether the deployed workflow accepts unauthenticated public API execution. While true, anyone holding the execution URL can run the workflow — and be billed for it — without an API key, so this is the field an audit of what a deployment exposes reads. Changed with `PATCH /workflows/{workflowId}/deployment`." + "description": "Whether anyone with the execution URL can run the deployed workflow and consume billed usage without an API key. Change this with Update Workflow Public API Access." } }, "required": [ @@ -8094,7 +8238,7 @@ ], "additionalProperties": false, "title": "Deploy result", - "description": "Deployment attempt accepted for processing. Activation is asynchronous, and `latestDeploymentAttempt` is the attempt handle — returned by every deployment mutation as well as this read. Poll activation with `isDeployed` and `deployedAt` on the workflow, or `isActive` on `GET /workflows/{workflowId}/versions`." + "description": "Deployment attempt accepted for asynchronous activation. `latestDeploymentAttempt` identifies the attempt. Poll Get Workflow Deployment for `isDeployed` and `deployedAt`, or List Workflow Versions for `isActive`." }, "DeployWorkflowResponse": { "type": "object", @@ -9268,7 +9412,7 @@ } }, "run": { - "description": "Workflow state and entry point to execute. Omit for the active deployment. Manual execution requires a personal API key with write access and supports synchronous or streamed runs only.", + "description": "Workflow state and entry point to execute. Omit for the active deployment. Manual execution requires OAuth or personal-key write access and supports synchronous or streamed runs only.", "oneOf": [ { "type": "object", @@ -9330,7 +9474,7 @@ "sourceRunId": { "type": "string", "minLength": 1, - "description": "Exact prior run whose persisted execution snapshot supplies upstream block state." + "description": "Run ID supplying upstream block results when starting from a selected block." } }, "required": ["type", "blockId", "sourceRunId"], @@ -9346,11 +9490,11 @@ }, "async": { "default": false, - "description": "Queue the run and return a 202 receipt when true. Requires an API key, cannot be combined with `stream`, and rejects all streaming and output-shaping options (`selectedOutputs`, `includeThinking`, `includeToolCalls`, `includeFileBase64`, `base64MaxBytes`).", + "description": "Queue the run and return a 202 receipt when true. Requires an OAuth access token or API key, cannot be combined with `stream`, and rejects all streaming and output-shaping options (`selectedOutputs`, `includeThinking`, `includeToolCalls`, `includeFileBase64`, `base64MaxBytes`).", "type": "boolean" }, "executionTimeoutSeconds": { - "description": "Requested server-side timeout for an asynchronous run, in seconds. An upper bound, not the effective timeout: the run uses the smaller of this value and the plan's execution timeout, so requesting more than the plan allows silently yields the plan timeout. Rejected with `400` unless `async` is true.", + "description": "Maximum duration of an asynchronous run, in seconds, capped by the plan's execution timeout. Requires `async: true`; otherwise returns `400`.", "type": "integer", "minimum": 1, "maximum": 604800 @@ -9361,7 +9505,7 @@ "type": "boolean" }, "selectedOutputs": { - "description": "Block output references to include in a streamed response. Use `.` for the executed workflow or `..` for a child workflow; block names are normalized workflow reference names. Selecting a child workflow applies to every invocation of it. Requires `stream: true` — it shapes the streamed envelope only, so it is rejected on a sync request and when `async` is true. To narrow a finished run, pass `selectedOutputs` to the run resource instead.", + "description": "Output references for streaming: `.` or `..`, using normalized block names. Child references apply to every invocation. Requires `stream: true` and rejects synchronous or async requests. Use `selectedOutputs` with Get Workflow Run to narrow an existing run.", "maxItems": 100, "type": "array", "items": { @@ -9392,7 +9536,7 @@ }, "additionalProperties": false, "title": "Execute workflow request", - "description": "Input, workflow-state selection, and execution-mode options. Each option carries the modes it requires and the modes that reject it; a violated combination is a 400.", + "description": "Input, workflow-state selection, and execution-mode options. Input descriptions specify compatible modes; invalid combinations return `400`.", "examples": [ { "input": { diff --git a/apps/docs/public/static/enterprise/entra/connection.png b/apps/docs/public/static/enterprise/entra/connection.png new file mode 100644 index 00000000000..48c0786a64d Binary files /dev/null and b/apps/docs/public/static/enterprise/entra/connection.png differ diff --git a/apps/docs/public/static/enterprise/entra/provision-user.png b/apps/docs/public/static/enterprise/entra/provision-user.png new file mode 100644 index 00000000000..3f5da82f483 Binary files /dev/null and b/apps/docs/public/static/enterprise/entra/provision-user.png differ diff --git a/apps/docs/public/static/enterprise/entra/scope.png b/apps/docs/public/static/enterprise/entra/scope.png new file mode 100644 index 00000000000..d8ac6e4c2d4 Binary files /dev/null and b/apps/docs/public/static/enterprise/entra/scope.png differ diff --git a/apps/docs/public/static/enterprise/okta/api-integration.png b/apps/docs/public/static/enterprise/okta/api-integration.png new file mode 100644 index 00000000000..1014e2d9d2a Binary files /dev/null and b/apps/docs/public/static/enterprise/okta/api-integration.png differ diff --git a/apps/docs/public/static/enterprise/okta/group-push.png b/apps/docs/public/static/enterprise/okta/group-push.png new file mode 100644 index 00000000000..bd158cf684a Binary files /dev/null and b/apps/docs/public/static/enterprise/okta/group-push.png differ diff --git a/apps/docs/public/static/enterprise/okta/provisioning-actions.png b/apps/docs/public/static/enterprise/okta/provisioning-actions.png new file mode 100644 index 00000000000..bb681e881f9 Binary files /dev/null and b/apps/docs/public/static/enterprise/okta/provisioning-actions.png differ diff --git a/apps/docs/public/static/enterprise/scim-provisioning.png b/apps/docs/public/static/enterprise/scim-provisioning.png new file mode 100644 index 00000000000..57e8a4d23b1 Binary files /dev/null and b/apps/docs/public/static/enterprise/scim-provisioning.png differ diff --git a/apps/docs/public/static/enterprise/sso-domains.png b/apps/docs/public/static/enterprise/sso-domains.png new file mode 100644 index 00000000000..9e0d1815c3c Binary files /dev/null and b/apps/docs/public/static/enterprise/sso-domains.png differ diff --git a/apps/docs/public/static/enterprise/sso-form.png b/apps/docs/public/static/enterprise/sso-form.png index f44f5f80d14..162d93d2ccc 100644 Binary files a/apps/docs/public/static/enterprise/sso-form.png and b/apps/docs/public/static/enterprise/sso-form.png differ diff --git a/apps/docs/public/static/search/add-source.jpg b/apps/docs/public/static/search/add-source.jpg new file mode 100644 index 00000000000..d2c1b6cbf2d Binary files /dev/null and b/apps/docs/public/static/search/add-source.jpg differ diff --git a/apps/docs/public/static/search/atlassian-oauth-callback.png b/apps/docs/public/static/search/atlassian-oauth-callback.png new file mode 100644 index 00000000000..23257a56eaf Binary files /dev/null and b/apps/docs/public/static/search/atlassian-oauth-callback.png differ diff --git a/apps/docs/public/static/search/confluence-setup.jpg b/apps/docs/public/static/search/confluence-setup.jpg new file mode 100644 index 00000000000..e2fe50864e4 Binary files /dev/null and b/apps/docs/public/static/search/confluence-setup.jpg differ diff --git a/apps/docs/public/static/search/connect-account.png b/apps/docs/public/static/search/connect-account.png new file mode 100644 index 00000000000..6f4bb5a06a0 Binary files /dev/null and b/apps/docs/public/static/search/connect-account.png differ diff --git a/apps/docs/public/static/search/github-app-callback.jpg b/apps/docs/public/static/search/github-app-callback.jpg new file mode 100644 index 00000000000..5ae4a20392b Binary files /dev/null and b/apps/docs/public/static/search/github-app-callback.jpg differ diff --git a/apps/docs/public/static/search/github-app-email-permission.jpg b/apps/docs/public/static/search/github-app-email-permission.jpg new file mode 100644 index 00000000000..a7086ca4739 Binary files /dev/null and b/apps/docs/public/static/search/github-app-email-permission.jpg differ diff --git a/apps/docs/public/static/search/github-app-repository-permissions.jpg b/apps/docs/public/static/search/github-app-repository-permissions.jpg new file mode 100644 index 00000000000..0e95461689e Binary files /dev/null and b/apps/docs/public/static/search/github-app-repository-permissions.jpg differ diff --git a/apps/docs/public/static/search/gitlab-options.jpg b/apps/docs/public/static/search/gitlab-options.jpg new file mode 100644 index 00000000000..96371a64fe8 Binary files /dev/null and b/apps/docs/public/static/search/gitlab-options.jpg differ diff --git a/apps/docs/public/static/search/gitlab-setup.jpg b/apps/docs/public/static/search/gitlab-setup.jpg new file mode 100644 index 00000000000..eceb8eab76e Binary files /dev/null and b/apps/docs/public/static/search/gitlab-setup.jpg differ diff --git a/apps/docs/public/static/search/gmail-setup.jpg b/apps/docs/public/static/search/gmail-setup.jpg new file mode 100644 index 00000000000..cdc89f465fc Binary files /dev/null and b/apps/docs/public/static/search/gmail-setup.jpg differ diff --git a/apps/docs/public/static/search/google-calendar-setup.jpg b/apps/docs/public/static/search/google-calendar-setup.jpg new file mode 100644 index 00000000000..f369fb01237 Binary files /dev/null and b/apps/docs/public/static/search/google-calendar-setup.jpg differ diff --git a/apps/docs/public/static/search/google-create-private-key.png b/apps/docs/public/static/search/google-create-private-key.png new file mode 100644 index 00000000000..ab21a326288 Binary files /dev/null and b/apps/docs/public/static/search/google-create-private-key.png differ diff --git a/apps/docs/public/static/search/google-create-service-account.png b/apps/docs/public/static/search/google-create-service-account.png new file mode 100644 index 00000000000..ec988ffcc24 Binary files /dev/null and b/apps/docs/public/static/search/google-create-service-account.png differ diff --git a/apps/docs/public/static/search/google-domain-delegation.png b/apps/docs/public/static/search/google-domain-delegation.png new file mode 100644 index 00000000000..b68680d0b82 Binary files /dev/null and b/apps/docs/public/static/search/google-domain-delegation.png differ diff --git a/apps/docs/public/static/search/google-drive-setup.jpg b/apps/docs/public/static/search/google-drive-setup.jpg new file mode 100644 index 00000000000..9e4e0af001d Binary files /dev/null and b/apps/docs/public/static/search/google-drive-setup.jpg differ diff --git a/apps/docs/public/static/search/google-oauth-web-client.png b/apps/docs/public/static/search/google-oauth-web-client.png new file mode 100644 index 00000000000..d7cba7d65c4 Binary files /dev/null and b/apps/docs/public/static/search/google-oauth-web-client.png differ diff --git a/apps/docs/public/static/search/google-service-account.jpg b/apps/docs/public/static/search/google-service-account.jpg new file mode 100644 index 00000000000..07c72b357fe Binary files /dev/null and b/apps/docs/public/static/search/google-service-account.jpg differ diff --git a/apps/docs/public/static/search/jira-setup.jpg b/apps/docs/public/static/search/jira-setup.jpg new file mode 100644 index 00000000000..75af1015724 Binary files /dev/null and b/apps/docs/public/static/search/jira-setup.jpg differ diff --git a/apps/docs/public/static/search/slack-app.jpg b/apps/docs/public/static/search/slack-app.jpg new file mode 100644 index 00000000000..ba66b20cb52 Binary files /dev/null and b/apps/docs/public/static/search/slack-app.jpg differ diff --git a/apps/docs/public/static/search/slack-setup.jpg b/apps/docs/public/static/search/slack-setup.jpg new file mode 100644 index 00000000000..9b701cd4427 Binary files /dev/null and b/apps/docs/public/static/search/slack-setup.jpg differ diff --git a/apps/realtime/src/handlers/connection.test.ts b/apps/realtime/src/handlers/connection.test.ts new file mode 100644 index 00000000000..26018de95f0 --- /dev/null +++ b/apps/realtime/src/handlers/connection.test.ts @@ -0,0 +1,73 @@ +/** + * @vitest-environment node + */ +import { createServer, type Server as HttpServer } from 'node:http' +import type { AddressInfo } from 'node:net' +import { Server } from 'socket.io' +import { io as connect, type Socket } from 'socket.io-client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { setupConnectionHandlers, waitForConnectionCleanup } from '@/handlers/connection' +import type { AuthenticatedSocket } from '@/middleware/auth' +import { MemoryRoomManager } from '@/rooms' + +vi.mock('@/handlers/file-doc', () => ({ cleanupFileDocForSocket: vi.fn() })) +vi.mock('@/handlers/subblocks', () => ({ cleanupPendingSubblocksForSocket: vi.fn() })) +vi.mock('@/handlers/variables', () => ({ cleanupPendingVariablesForSocket: vi.fn() })) + +describe('server shutdown connection drain', () => { + let httpServer: HttpServer + let io: Server + let manager: MemoryRoomManager + let client: Socket + + beforeEach(async () => { + httpServer = createServer() + io = new Server(httpServer, { transports: ['websocket'] }) + manager = new MemoryRoomManager(io) + await manager.initialize() + io.on('connection', (socket) => setupConnectionHandlers(socket as AuthenticatedSocket, manager)) + await new Promise((resolve) => httpServer.listen(0, '127.0.0.1', resolve)) + const port = (httpServer.address() as AddressInfo).port + client = connect(`http://127.0.0.1:${port}`, { transports: ['websocket'], autoConnect: false }) + const connected = new Promise((resolve) => client.once('connect', resolve)) + client.connect() + await connected + }) + + afterEach(async () => { + client.disconnect() + await io.close() + await waitForConnectionCleanup() + await manager.shutdown() + vi.restoreAllMocks() + }) + + it('keeps automatic reconnection active after transport shutdown', async () => { + const disconnected = new Promise((resolve) => client.once('disconnect', resolve)) + await io.close() + expect(await disconnected).toBe('transport close') + expect(client.active).toBe(true) + await waitForConnectionCleanup() + }) + + it('waits for asynchronous presence cleanup before releasing its dependencies', async () => { + let finishRemoval: (() => void) | undefined + vi.spyOn(manager, 'removeSocketFromAllRooms').mockImplementation( + () => + new Promise((resolve) => { + finishRemoval = () => resolve([]) + }) + ) + await io.close() + let drained = false + const drain = waitForConnectionCleanup().then(() => { + drained = true + }) + await Promise.resolve() + expect(drained).toBe(false) + expect(finishRemoval).toBeDefined() + finishRemoval?.() + await drain + expect(drained).toBe(true) + }) +}) diff --git a/apps/realtime/src/handlers/connection.ts b/apps/realtime/src/handlers/connection.ts index 33d90b5bfb0..fcbdaade40f 100644 --- a/apps/realtime/src/handlers/connection.ts +++ b/apps/realtime/src/handlers/connection.ts @@ -16,6 +16,13 @@ const logger = createLogger('ConnectionHandlers') */ const PRESENCE_BEARING_TYPES = new Set([ROOM_TYPES.WORKFLOW, ROOM_TYPES.TABLE]) +const pendingDisconnects = new Set>() + +/** Keep Redis available until disconnect listeners finish removing presence. */ +export async function waitForConnectionCleanup(): Promise { + await Promise.all(pendingDisconnects) +} + export function setupConnectionHandlers(socket: AuthenticatedSocket, roomManager: IRoomManager) { socket.on('error', (error) => { logger.error(`Socket ${socket.id} error:`, error) @@ -28,7 +35,7 @@ export function setupConnectionHandlers(socket: AuthenticatedSocket, roomManager // `disconnecting` (not `disconnect`): here `socket.rooms` is still populated and // authoritative, so presence is cleaned up even if the Redis room-set key was // evicted or TTL-expired (which would leave the manager's stored rooms empty). - socket.on('disconnecting', async (reason) => { + const handleDisconnect = async (reason: string) => { try { // Snapshot the live Socket.IO room membership SYNCHRONOUSLY, before any // await: Socket.IO clears `socket.rooms` via leaveAll() as soon as the @@ -91,5 +98,11 @@ export function setupConnectionHandlers(socket: AuthenticatedSocket, roomManager } catch (error) { logger.error(`Error handling disconnect for socket ${socket.id}:`, error) } + } + + socket.on('disconnecting', (reason) => { + const cleanup = handleDisconnect(reason) + pendingDisconnects.add(cleanup) + void cleanup.finally(() => pendingDisconnects.delete(cleanup)) }) } diff --git a/apps/realtime/src/handlers/file-doc-app.ts b/apps/realtime/src/handlers/file-doc-app.ts index 87e515954be..072d94f20d6 100644 --- a/apps/realtime/src/handlers/file-doc-app.ts +++ b/apps/realtime/src/handlers/file-doc-app.ts @@ -18,10 +18,8 @@ function postToApp(path: string, payload: unknown, timeoutMs: number): Promise +} + +interface TestRedisClient { + isOpen: boolean + connect(): Promise + quit(): Promise + on(): TestRedisClient + duplicate(): TestRedisClient + xAdd(key: string, id: string, fields: Record): Promise + xRange( + key: string, + start: string, + end: string, + options?: { COUNT?: number } + ): Promise + xRevRange( + key: string, + start: string, + end: string, + options?: { COUNT?: number } + ): Promise + xLen(key: string): Promise + xTrim(key: string, strategy: string, minId: string): Promise + xRead( + streams: { key: string; id: string }[], + options?: { BLOCK?: number; COUNT?: number } + ): Promise<{ name: string; messages: TestStreamEntry[] }[] | null> + set(key: string, value: string, options?: { NX?: boolean }): Promise + get(key: string): Promise + del(keys: string | string[]): Promise + eval( + script: string, + options: { keys: string[]; arguments: string[] } + ): Promise + expire(): Promise +} + /** * One shared in-memory Redis backing per test, so several {@link FileDocStore} instances (modelling * several ECS tasks) all talk to the "same Redis". A minimal fake of just the stream/lock ops the * store uses. */ interface Backing { - streams: Map }[]> + streams: Map kv: Map + dedupe: Map seq: number + /** Override generated IDs to model a recreated stream restarting its same-millisecond sequence. */ + nextIds?: string[] /** Number of upcoming xAdd calls to fail with a transient error (to exercise publish retry). */ failXAdd: number /** Set to fail every xRead the way node-redis does once a client has been closed. */ @@ -26,18 +70,34 @@ interface Backing { idleReads: number /** `connect()` calls, so a test can prove a closed reader is re-opened rather than abandoned. */ connects: number + /** Largest stream range response requested, proving replay is paginated. */ + maxRangeCount: number + /** Optional deterministic compaction hook invoked before each range page is read. */ + onRange?: (call: number, key: string, start: string) => void + rangeCalls: number + /** Largest multiplexed XREAD request and COUNT observed. */ + maxReadStreams: number + maxReadCount: number + onSnapshot?: () => Promise + failSnapshotTrim?: boolean + onLength?: () => Promise } const state = vi.hoisted(() => ({ backing: null as Backing | null })) -const seqOf = (id: string) => Number(id.split('-')[0]) +function compareStreamIds(left: string, right: string): bigint { + const [leftMs, leftSequence] = left.split('-').map(BigInt) + const [rightMs, rightSequence] = right.split('-').map(BigInt) + return leftMs === rightMs ? leftSequence - rightSequence : leftMs - rightMs +} -function makeClient(): any { +function makeClient(): TestRedisClient { const b = () => { if (!state.backing) throw new Error('backing not initialized') return state.backing } - const client: any = { + const nextId = () => b().nextIds?.shift() ?? `${++b().seq}-0` + const client: TestRedisClient = { isOpen: true, connect: async () => { client.isOpen = true @@ -51,32 +111,58 @@ function makeClient(): any { b().failXAdd-- throw new Error('transient xAdd failure') } - const id = `${++b().seq}-0` + const id = nextId() const arr = b().streams.get(key) ?? [] arr.push({ id, message: { ...fields } }) b().streams.set(key, arr) return id }, - xRange: async (key: string) => (b().streams.get(key) ?? []).map((e) => ({ ...e })), - xLen: async (key: string) => (b().streams.get(key) ?? []).length, + xRange: async (key: string, start: string, end: string, options?: { COUNT?: number }) => { + b().rangeCalls++ + b().onRange?.(b().rangeCalls, key, start) + const startId = start.startsWith('(') ? start.slice(1) : start + const entries = (b().streams.get(key) ?? []).filter( + (entry) => + (start === '-' || compareStreamIds(entry.id, startId) > 0n) && + (end === '+' || compareStreamIds(entry.id, end) <= 0n) + ) + const count = options?.COUNT ?? entries.length + b().maxRangeCount = Math.max(b().maxRangeCount, count) + return entries.slice(0, count).map((entry) => ({ ...entry })) + }, + xRevRange: async (key: string, _start: string, _end: string, options?: { COUNT?: number }) => + [...(b().streams.get(key) ?? [])] + .reverse() + .slice(0, options?.COUNT) + .map((entry) => ({ ...entry })), + xLen: async (key: string) => { + await b().onLength?.() + return (b().streams.get(key) ?? []).length + }, xTrim: async (key: string, _strategy: string, minid: string) => { const arr = b().streams.get(key) ?? [] b().streams.set( key, - arr.filter((e) => seqOf(e.id) >= seqOf(minid)) + arr.filter((e) => compareStreamIds(e.id, minid) >= 0n) ) }, - xRead: async (streams: { key: string; id: string }[]) => { + xRead: async ( + streams: { key: string; id: string }[], + options?: { BLOCK?: number; COUNT?: number } + ) => { b().reads++ + b().maxReadStreams = Math.max(b().maxReadStreams, streams.length) + b().maxReadCount = Math.max(b().maxReadCount, options?.COUNT ?? 0) if (b().readerClosed) { b().failedReadTimes.push(Date.now()) client.isOpen = false throw new Error('The client is closed') } - const res: { name: string; messages: { id: string; message: Record }[] }[] = - [] + const res: { name: string; messages: TestStreamEntry[] }[] = [] for (const { key, id } of streams) { - const after = (b().streams.get(key) ?? []).filter((e) => seqOf(e.id) > seqOf(id)) + const after = (b().streams.get(key) ?? []) + .filter((e) => compareStreamIds(e.id, id) > 0n) + .slice(0, options?.COUNT) if (after.length) res.push({ name: key, messages: after.map((e) => ({ ...e })) }) } if (res.length) { @@ -92,24 +178,131 @@ function makeClient(): any { b().kv.set(key, val) return 'OK' }, - del: async (key: string) => { - b().kv.delete(key) - return 1 + get: async (key: string) => b().kv.get(key) ?? null, + del: async (keys: string | string[]) => { + const targets = Array.isArray(keys) ? keys : [keys] + for (const key of targets) { + b().kv.delete(key) + b().streams.delete(key) + b().dedupe.delete(key) + } + return targets.length }, eval: async (script: string, opts: { keys: string[]; arguments: string[] }) => { const [key] = opts.keys + if (script.startsWith('for _, key in ipairs(KEYS)')) return 1 + if (script.includes("redis.call('exists', KEYS[1])") && !b().streams.has(key)) { + return script.includes('zscore') ? -1 : false + } + if (script.includes('return ARGV[1]')) { + const generation = b().kv.get(opts.keys[1]) + if (generation !== undefined) return generation + if (!b().streams.get(key)?.length) return false + b().kv.set(opts.keys[1], opts.arguments[0]) + return opts.arguments[0] + } + if (script.includes("redis.call('del', KEYS[1], KEYS[4], KEYS[5])")) { + const [, generationKey, versionKey, dedupeKey, agentKey, invalidationKey] = opts.keys + const [version, , marker] = opts.arguments + const current = b().kv.get(versionKey) + const invalidated = b().kv.get(invalidationKey) + if (invalidated && Number(invalidated) >= Number(version)) return null + if (current && Number(current) > Number(version)) return null + if (current === version && b().kv.get(generationKey) === marker) return null + const generation = b().kv.get(generationKey) ?? '' + b().kv.set(generationKey, marker) + b().kv.set(versionKey, version) + b().kv.set(invalidationKey, version) + b().streams.delete(key) + b().dedupe.delete(dedupeKey) + b().kv.delete(agentKey) + return generation + } + if (script.includes('zscore')) { + const [, dedupeKey, generationKey] = opts.keys + const [member, field, value, capacityText, , expectedGeneration] = opts.arguments + const generation = b().kv.get(generationKey) + if ((generation ?? '') !== expectedGeneration) return -1 + const members = b().dedupe.get(dedupeKey) ?? [] + if (members.includes(member)) return 0 + const id = nextId() + const arr = b().streams.get(key) ?? [] + arr.push({ id, message: { [field]: value } }) + b().streams.set(key, arr) + members.push(member) + const capacity = Number(capacityText) + if (members.length > capacity) members.splice(0, members.length - capacity) + b().dedupe.set(dedupeKey, members) + return 1 + } // Atomic seed-if-empty (SEED_IF_EMPTY_SCRIPT): append the entry iff the stream is empty, in one // synchronous step — mirroring Redis's atomic Lua execution, so two concurrent evals can never both // append (the second sees a non-empty stream). if (script.includes('xlen')) { - const [field, value] = opts.arguments + const [, generationKey, versionKey] = opts.keys + const [field, value, generation, , generationField, version] = opts.arguments + if (Number(b().kv.get(versionKey) ?? 0) > Number(version)) return 0 const arr = b().streams.get(key) ?? [] if (arr.length > 0) return 0 - const id = `${++b().seq}-0` - arr.push({ id, message: { [field]: value } }) + b().kv.set(generationKey, generation) + if (version !== '0') b().kv.set(versionKey, version) + const id = nextId() + arr.push({ id, message: { [field]: value, [generationField]: generation } }) b().streams.set(key, arr) return 1 } + if (script.includes('ARGV[5], ARGV[4]')) { + const [, generationKey] = opts.keys + const [field, value, marker, expectedGeneration, generationField, upTo, compactionField] = + opts.arguments + const generation = b().kv.get(generationKey) + if ((generation ?? '') !== expectedGeneration) return false + const id = nextId() + const arr = b().streams.get(key) ?? [] + arr.push({ + id, + message: { + [field]: value, + [marker]: '1', + [generationField]: expectedGeneration, + ...(compactionField ? { [compactionField]: '1' } : {}), + }, + }) + b().streams.set(key, arr) + if (script.includes("redis.call('xtrim'")) { + if (b().failSnapshotTrim) throw new Error('snapshot trim failed') + b().streams.set( + key, + arr.filter((entry) => compareStreamIds(entry.id, upTo) >= 0n) + ) + } + await b().onSnapshot?.() + return id + } + if (script.includes("ARGV[3] ~= ''")) { + const [, generationKey] = opts.keys + const generation = b().kv.get(generationKey) + const expectedGeneration = opts.arguments[3] + if ((generation ?? '') !== expectedGeneration) return false + if (b().failXAdd > 0) { + b().failXAdd-- + throw new Error('transient xAdd failure') + } + const [field, value, marker] = opts.arguments + const id = nextId() + const arr = b().streams.get(key) ?? [] + arr.push({ id, message: { [field]: value, ...(marker ? { [marker]: '1' } : {}) } }) + b().streams.set(key, arr) + return id + } + if (script.includes('tonumber(c)')) { + const [value, , expectedGeneration] = opts.arguments + const generation = b().kv.get(opts.keys[1]) + if ((generation ?? '') !== expectedGeneration) return 0 + const current = b().kv.get(key) + if (current === undefined || Number(current) < Number(value)) b().kv.set(key, value) + return 1 + } // Compare-and-delete Lua (RELEASE_LOCK_SCRIPT): del only if the stored value matches the token. const [token] = opts.arguments if (b().kv.get(key) === token) { @@ -130,6 +323,35 @@ import { FileDocStore, REDIS_AGENT_ORIGIN, REDIS_ORIGIN } from '@/handlers/file- const REDIS_URL = 'redis://fake' const NAME = 'workspace-file-doc:file-1' +interface StoreRoomTestAccess { + doc: Y.Doc + lastId: string + publishes: number + uncompactedDeltaBytes: number + lastDeltaBytes: number + compactRetryAfter: number + compacting: boolean + seededObserved: boolean + realEdited: boolean +} + +interface StoreTestAccess { + localInvalidations: Map + rooms: Map + maybeCompact(name: string): Promise + appendUpdate(name: string, update: Uint8Array): Promise + applyEntry( + name: string, + room: StoreRoomTestAccess, + id: string, + message: Record + ): void +} + +function storeInternals(store: FileDocStore): StoreTestAccess { + return store as unknown as StoreTestAccess +} + function docWithText(text: string): Y.Doc { const doc = new Y.Doc() doc.getText('body').insert(0, text) @@ -144,7 +366,28 @@ function updateFor(text: string): Uint8Array { return update } +function seedFor(text: string): Uint8Array { + const doc = docWithText(text) + const config = doc.getMap(FILE_DOC_SEED.configMap) + config.set(FILE_DOC_SEED.flag, true) + config.set(FILE_DOC_SEED.docIdKey, `doc-${text}`) + try { + return Y.encodeStateAsUpdate(doc) + } finally { + doc.destroy() + } +} + let stores: FileDocStore[] = [] + +/** An existing stream from a relay predating generation markers; modern seeds use seedIfEmpty. */ +function seedLegacyStream(update = updateFor('')): void { + const backing = state.backing! + backing.streams.set(`filedoc:stream:${NAME}`, [ + { id: `${++backing.seq}-0`, message: { u: Buffer.from(update).toString('base64') } }, + ]) +} + async function newStore(): Promise { const store = new FileDocStore(REDIS_URL) await store.init() @@ -157,6 +400,7 @@ describe('FileDocStore', () => { state.backing = { streams: new Map(), kv: new Map(), + dedupe: new Map(), seq: 0, failXAdd: 0, readerClosed: false, @@ -164,6 +408,10 @@ describe('FileDocStore', () => { failedReadTimes: [], idleReads: 0, connects: 0, + maxRangeCount: 0, + rangeCalls: 0, + maxReadStreams: 0, + maxReadCount: 0, } stores = [] }) @@ -264,7 +512,7 @@ describe('FileDocStore', () => { const token = await a.shouldSeed(NAME) expect(token).toBeTruthy() // A seeds and releases its lock. - a.publish(NAME, updateFor('hello')) + await a.seedIfEmpty(NAME, seedFor('hello')) await vi.waitFor(async () => expect(await a.getStreamState(NAME)).not.toBeNull()) await a.releaseSeedLock(NAME, token as string) // A different task must NOT seed again — the lock is free but the stream is non-empty. @@ -272,9 +520,31 @@ describe('FileDocStore', () => { expect(await b.shouldSeed(NAME)).toBeNull() }) + it('fences stale publishers after invalidation and lets the next authoritative seed start fresh', async () => { + const store = await newStore() + const original = seedFor('old generation') + await store.seedIfEmpty(NAME, original) + await store.invalidateDocument(NAME, 10) + + await expect(store.getStreamState(NAME)).resolves.toBeNull() + await expect(store.publishAndWait(NAME, updateFor('stale write'))).rejects.toThrow( + 'replaced by a newer durable version' + ) + await expect( + store.publishClientUpdateAndWait(NAME, 'stale-update', updateFor('stale acknowledged write')) + ).rejects.toThrow('replaced by a newer durable version') + + const fresh = seedFor('fresh generation') + await expect(store.seedIfEmpty(NAME, fresh, 11)).resolves.toBe(true) + const recovered = new Y.Doc() + Y.applyUpdate(recovered, (await store.getStreamState(NAME))!) + expect(recovered.getText('body').toString()).toBe('fresh generation') + recovered.destroy() + }) + it('getStreamState reconstructs the shared document from the stream', async () => { const a = await newStore() - a.publish(NAME, updateFor('shared content')) + await a.seedIfEmpty(NAME, seedFor('shared content')) let state: Uint8Array | null = null await vi.waitFor(async () => { state = await a.getStreamState(NAME) @@ -286,9 +556,403 @@ describe('FileDocStore', () => { doc.destroy() }) + it('lets a headless replica append against the generation of its shared base', async () => { + const seeded = await newStore() + await seeded.seedIfEmpty(NAME, seedFor('shared'), 20) + const headless = await newStore() + const generation = await headless.getDocumentGeneration(NAME) + const doc = new Y.Doc() + Y.applyUpdate(doc, (await headless.getStreamState(NAME, generation))!) + const before = Y.encodeStateVector(doc) + doc.getText('body').insert(6, ' edit') + await headless.publishAndWait(NAME, Y.encodeStateAsUpdate(doc, before), generation) + const replay = new Y.Doc() + Y.applyUpdate(replay, (await seeded.getStreamState(NAME))!) + expect(replay.getText('body').toString()).toBe('shared edit') + doc.destroy() + replay.destroy() + }) + + it('keeps a newer seeded generation when an older invalidation arrives', async () => { + const store = await newStore() + await store.seedIfEmpty(NAME, seedFor('newest'), 20) + const generation = await store.getDocumentGeneration(NAME) + await expect(store.invalidateDocument(NAME, 10)).resolves.toEqual({ status: 'stale' }) + expect(await store.getDocumentGeneration(NAME)).toBe(generation) + expect(await store.getSyncedVersion(NAME)).toBe(20) + await expect(store.getStreamState(NAME)).resolves.not.toBeNull() + }) + + it('rejects old seeds and version callbacks after an invalidation', async () => { + const store = await newStore() + await store.seedIfEmpty(NAME, seedFor('old'), 10) + const generation = await store.getDocumentGeneration(NAME) + await store.invalidateDocument(NAME, 20) + await expect(store.seedIfEmpty(NAME, seedFor('late stale seed'), 10)).resolves.toBe(false) + await store.setSyncedVersion(NAME, 30, generation) + expect(await store.getSyncedVersion(NAME)).toBe(20) + await expect(store.getStreamState(NAME)).resolves.toBeNull() + }) + + it('does not repeat an invalidation after the same durable version is reseeded', async () => { + const store = await newStore() + await store.seedIfEmpty(NAME, seedFor('old'), 10) + await expect(store.invalidateDocument(NAME, 20)).resolves.toMatchObject({ status: 'applied' }) + await expect(store.seedIfEmpty(NAME, seedFor('replacement'), 20)).resolves.toBe(true) + const generation = await store.getDocumentGeneration(NAME) + const doc = new Y.Doc() + Y.applyUpdate(doc, (await store.getStreamState(NAME))!) + const before = Y.encodeStateVector(doc) + doc.getText('body').insert(11, ' accepted') + await store.publishClientUpdateAndWait( + NAME, + 'accepted-edit', + Y.encodeStateAsUpdate(doc, before), + generation + ) + + await expect(store.invalidateDocument(NAME, 20)).resolves.toEqual({ status: 'stale' }) + expect(await store.getDocumentGeneration(NAME)).toBe(generation) + const recovered = new Y.Doc() + Y.applyUpdate(recovered, (await store.getStreamState(NAME))!) + expect(recovered.getText('body').toString()).toBe('replacement accepted') + expect(state.backing!.dedupe.get(`filedoc:updates:${NAME}`)).toHaveLength(1) + doc.destroy() + recovered.destroy() + }) + + it('applies the first invalidation even when its durable version was already seeded', async () => { + const store = await newStore() + await store.seedIfEmpty(NAME, seedFor('same content, changed eligibility'), 20) + const docId = await store.getDocumentGeneration(NAME) + await expect(store.invalidateDocument(NAME, 20)).resolves.toEqual({ status: 'applied', docId }) + await expect(store.invalidateDocument(NAME, 20)).resolves.toEqual({ status: 'stale' }) + await expect(store.getStreamState(NAME)).resolves.toBeNull() + }) + + it('returns the removed generation and qualifies consecutive unsupported replacements', async () => { + const store = await newStore() + await store.seedIfEmpty(NAME, seedFor('old'), 10) + const oldId = await store.getDocumentGeneration(NAME) + await expect(store.invalidateDocument(NAME, 20)).resolves.toEqual({ + status: 'applied', + docId: oldId, + }) + await expect(store.invalidateDocument(NAME, 30)).resolves.toEqual({ status: 'applied' }) + await store.seedIfEmpty(NAME, seedFor('replacement'), 30) + const replacementId = await store.getDocumentGeneration(NAME) + expect(replacementId).not.toBe(oldId) + await expect(store.invalidateDocument(NAME, 30)).resolves.toEqual({ status: 'stale' }) + await expect(store.invalidateDocument(NAME, 40)).resolves.toEqual({ + status: 'applied', + docId: replacementId, + }) + }) + + it('does not resurrect a tracked stream with a dependency-only update after Redis loses it', async () => { + const store = await newStore() + await store.seedIfEmpty(NAME, seedFor('base'), 10) + const generation = await store.getDocumentGeneration(NAME) + state.backing!.streams.delete(`filedoc:stream:${NAME}`) + state.backing!.kv.delete(`filedoc:generation:${NAME}`) + await expect(store.publishAndWait(NAME, updateFor('stale'), generation)).rejects.toThrow( + 'replaced' + ) + await expect( + store.publishClientUpdateAndWait(NAME, 'lost-stream-update', updateFor('stale'), generation) + ).rejects.toThrow('replaced') + await expect(store.getStreamState(NAME)).resolves.toBeNull() + }) + + it('rejects appends and duplicate acknowledgements when only the stream is lost', async () => { + const store = await newStore() + await store.seedIfEmpty(NAME, seedFor('base'), 10) + const generation = await store.getDocumentGeneration(NAME) + const delta = updateFor('edit') + await store.publishClientUpdateAndWait(NAME, 'accepted-update', delta, generation) + state.backing!.streams.delete(`filedoc:stream:${NAME}`) + + await expect(store.publishAndWait(NAME, delta, generation)).rejects.toThrow('replaced') + await expect( + store.publishClientUpdateAndWait(NAME, 'new-update', delta, generation) + ).rejects.toThrow('replaced') + await expect( + store.publishClientUpdateAndWait(NAME, 'accepted-update', delta, generation) + ).rejects.toThrow('replaced') + expect(state.backing!.streams.has(`filedoc:stream:${NAME}`)).toBe(false) + }) + + it('adopts the identity of a pre-upgrade stream before acknowledging its edits', async () => { + const store = await newStore() + const seed = new Y.Doc() + seed.getMap('config').set('initialContentLoaded', true) + seed.getMap('config').set('docId', 'legacy-document') + seed.getText('body').insert(0, 'legacy') + seedLegacyStream(Y.encodeStateAsUpdate(seed)) + const attached = new Y.Doc() + await store.attachRoom(NAME, attached) + expect(await store.getDocumentGeneration(NAME)).toBe('legacy-document') + const before = Y.encodeStateVector(seed) + seed.getText('body').insert(6, ' edit') + await expect( + store.publishClientUpdateAndWait( + NAME, + 'legacy-edit', + Y.encodeStateAsUpdate(seed, before), + 'legacy-document' + ) + ).resolves.toBeUndefined() + store.detachRoom(NAME) + seed.destroy() + attached.destroy() + }) + + it('rejects a shared replay if the document generation changes between pages', async () => { + const store = await newStore() + await store.seedIfEmpty(NAME, seedFor('old generation'), 10) + state.backing!.onRange = () => { + state.backing!.kv.set(`filedoc:generation:${NAME}`, 'new generation') + } + await expect(store.getStreamState(NAME)).rejects.toThrow('replaced') + }) + + it.each([true, false])( + 'validates a modern snapshot following a legacy seed (same identity: %s)', + async (sameIdentity) => { + const store = await newStore() + const seed = new Y.Doc() + seed.getMap('config').set('initialContentLoaded', true) + seed.getMap('config').set('docId', 'legacy-document') + seed.getText('body').insert(0, 'legacy') + seedLegacyStream(Y.encodeStateAsUpdate(seed)) + const backing = state.backing! + backing.kv.set( + `filedoc:generation:${NAME}`, + sameIdentity ? 'legacy-document' : 'different-document' + ) + backing.streams.get(`filedoc:stream:${NAME}`)!.push({ + id: `${++backing.seq}-0`, + message: { + u: Buffer.from(Y.encodeStateAsUpdate(seed)).toString('base64'), + s: '1', + g: sameIdentity ? 'legacy-document' : 'different-document', + }, + }) + const attached = new Y.Doc() + if (sameIdentity) { + await store.attachRoom(NAME, attached) + const before = Y.encodeStateVector(seed) + seed.getText('body').insert(6, ' peer') + await store.publishClientUpdateAndWait( + NAME, + 'peer-edit', + Y.encodeStateAsUpdate(seed, before), + 'legacy-document' + ) + await store.catchUp(NAME) + expect(attached.getText('body').toString()).toBe('legacy peer') + store.detachRoom(NAME) + } else { + await expect(store.attachRoom(NAME, attached)).rejects.toThrow('replaced') + expect(storeInternals(store).rooms.has(NAME)).toBe(false) + } + seed.destroy() + attached.destroy() + } + ) + + it('replays stream history in bounded pages', async () => { + const streamKey = `filedoc:stream:${NAME}` + const noop = Buffer.from(updateFor('')).toString('base64') + state.backing!.streams.set( + streamKey, + Array.from({ length: 40 }, (_, index) => ({ + id: `${index + 1}-0`, + message: { u: noop }, + })) + ) + state.backing!.seq = 40 + const store = await newStore() + + await expect(store.getStreamState(NAME)).resolves.not.toBeNull() + + expect(state.backing!.maxRangeCount).toBe(4) + }) + + it('fails safely when an uncompacted stream exceeds the replay entry budget', async () => { + const streamKey = `filedoc:stream:${NAME}` + const noop = Buffer.from(updateFor('')).toString('base64') + state.backing!.streams.set( + streamKey, + Array.from({ length: 2_001 }, (_, index) => ({ + id: `${index + 1}-0`, + message: { u: noop }, + })) + ) + state.backing!.seq = 2_001 + const store = await newStore() + + await expect(store.getStreamState(NAME)).rejects.toThrow('replay exceeded its safety limit') + }) + + it('never exposes a partially replayed document when room attachment exceeds its budget', async () => { + const streamKey = `filedoc:stream:${NAME}` + const noop = Buffer.from(updateFor('')).toString('base64') + state.backing!.streams.set( + streamKey, + Array.from({ length: 2_001 }, (_, index) => ({ + id: `${index + 1}-0`, + message: { u: noop }, + })) + ) + state.backing!.seq = 2_001 + const store = await newStore() + const doc = new Y.Doc() + + await expect(store.attachRoom(NAME, doc)).rejects.toThrow('replay exceeded its safety limit') + + expect(doc.getText('body').toString()).toBe('') + expect(storeInternals(store).rooms.has(NAME)).toBe(false) + doc.destroy() + }) + + it('recovers from compaction that trims unread pages during replay', async () => { + const streamKey = `filedoc:stream:${NAME}` + const noop = Buffer.from(updateFor('')).toString('base64') + state.backing!.streams.set( + streamKey, + Array.from({ length: 8 }, (_, index) => ({ + id: `${index + 1}-0`, + message: { u: noop }, + })) + ) + state.backing!.seq = 8 + state.backing!.onRange = (call, key) => { + if (call !== 2 || key !== streamKey) return + state.backing!.streams.set(streamKey, [ + { + id: '9-0', + message: { u: Buffer.from(updateFor('compacted')).toString('base64'), s: '1' }, + }, + ]) + state.backing!.seq = 9 + state.backing!.onRange = undefined + } + const store = await newStore() + + const recovered = new Y.Doc() + Y.applyUpdate(recovered, (await store.getStreamState(NAME))!) + expect(recovered.getText('body').toString()).toBe('compacted') + recovered.destroy() + }) + + it.each(['headless', 'attached'] as const)( + 'does not recount a replacement snapshot near the byte budget during %s replay', + async (mode) => { + const streamKey = `filedoc:stream:${NAME}` + const source = new Y.Doc() + source.getText('body').insert(0, 'x'.repeat(10 * 1024 * 1024)) + const initial = Buffer.from(Y.encodeStateAsUpdate(source)).toString('base64') + const noop = Buffer.from(updateFor('')).toString('base64') + state.backing!.streams.set( + streamKey, + Array.from({ length: 8 }, (_, index) => ({ + id: `${index + 1}-0`, + message: { u: index === 0 ? initial : noop }, + })) + ) + source.getText('body').insert(source.getText('body').length, ' joined') + const compacted = Buffer.from(Y.encodeStateAsUpdate(source)).toString('base64') + state.backing!.seq = 8 + state.backing!.onRange = (_call, key, start) => { + if (key !== streamKey || start !== '(4-0') return + state.backing!.streams.set(streamKey, [{ id: '9-0', message: { u: compacted, s: '1' } }]) + state.backing!.seq = 9 + state.backing!.onRange = undefined + } + const store = await newStore() + const recovered = new Y.Doc() + try { + if (mode === 'attached') await store.attachRoom(NAME, recovered) + else Y.applyUpdate(recovered, (await store.getStreamState(NAME))!) + expect(recovered.getText('body').toString()).toBe(source.getText('body').toString()) + } finally { + store.detachRoom(NAME) + recovered.destroy() + source.destroy() + } + } + ) + + it('does not recount retained entries when compaction meets the exact entry budget', async () => { + const streamKey = `filedoc:stream:${NAME}` + const noop = Buffer.from(updateFor('')).toString('base64') + const entries = Array.from({ length: 1_999 }, (_, index) => ({ + id: `${index + 1}-0`, + message: { u: noop }, + })) + state.backing!.streams.set(streamKey, entries) + state.backing!.seq = 1_999 + state.backing!.onRange = (_call, key, start) => { + if (key !== streamKey || start !== '(1996-0') return + state.backing!.streams.set(streamKey, [ + ...entries.slice(1_996), + { + id: '2000-0', + message: { u: Buffer.from(updateFor('complete')).toString('base64'), s: '1' }, + }, + ]) + state.backing!.seq = 2_000 + state.backing!.onRange = undefined + } + const store = await newStore() + const recovered = new Y.Doc() + try { + Y.applyUpdate(recovered, (await store.getStreamState(NAME))!) + expect(recovered.getText('body').toString()).toBe('complete') + } finally { + recovered.destroy() + } + }) + + it('reads the replacement snapshot when peer deltas cross the old replay tail', async () => { + const streamKey = `filedoc:stream:${NAME}` + const source = new Y.Doc() + const entries: Array<{ id: string; message: Record }> = [] + source.on('update', (update: Uint8Array) => { + entries.push({ + id: `${entries.length + 1}-0`, + message: { u: Buffer.from(update).toString('base64') }, + }) + }) + for (let i = 1; i <= 8; i++) + source.getText('body').insert(source.getText('body').length, String(i)) + const snapshot = Y.encodeStateAsUpdate(source) + state.backing!.streams.set(streamKey, entries.slice()) + for (let i = 9; i <= 11; i++) + source.getText('body').insert(source.getText('body').length, String(i)) + state.backing!.onRange = (_call, key, start) => { + if (key !== streamKey || start !== '(4-0') return + state.backing!.streams.set(streamKey, [ + ...entries.slice(7), + { id: '12-0', message: { u: Buffer.from(snapshot).toString('base64'), s: '1' } }, + ]) + state.backing!.onRange = undefined + } + const store = await newStore() + const recovered = new Y.Doc() + try { + Y.applyUpdate(recovered, (await store.getStreamState(NAME))!) + expect(recovered.getText('body').toString()).toBe(source.getText('body').toString()) + } finally { + source.destroy() + recovered.destroy() + } + }) + it('attachRoom catches a fresh task up to the current shared state', async () => { const a = await newStore() - a.publish(NAME, updateFor('already here')) + await a.seedIfEmpty(NAME, seedFor('already here')) await vi.waitFor(async () => expect(await a.getStreamState(NAME)).not.toBeNull()) // A second task opens the same file: its doc must load the existing content, not start empty. @@ -300,6 +964,7 @@ describe('FileDocStore', () => { }) it('converges a peer task via the tailer after attach', async () => { + seedLegacyStream() const a = await newStore() const b = await newStore() const bDoc = new Y.Doc() @@ -338,14 +1003,18 @@ describe('FileDocStore', () => { const a = await newStore() // This task has integrated only up to entry 400 (all no-ops) — its local doc is empty and lags the // two peer entries. Inject that lagging room directly (a real edit was integrated → realEdited). - ;(a as any).rooms.set(NAME, { + storeInternals(a).rooms.set(NAME, { doc: new Y.Doc(), lastId: '400-0', publishes: 0, + uncompactedDeltaBytes: 0, + lastDeltaBytes: 0, + compactRetryAfter: 0, + compacting: false, seededObserved: true, realEdited: true, }) - await (a as any).maybeCompact(NAME) + await storeInternals(a).maybeCompact(NAME) // A fresh catch-up must still reconstruct the peer content — compaction must not have trimmed 401/402. const doc = new Y.Doc() @@ -360,6 +1029,7 @@ describe('FileDocStore', () => { }) it('tags an agent-streamed frame so a peer tailer applies it as REDIS_AGENT_ORIGIN (never persisted)', async () => { + seedLegacyStream() const streamKey = `filedoc:stream:${NAME}` const a = await newStore() const b = await newStore() @@ -385,22 +1055,191 @@ describe('FileDocStore', () => { }) it('latches realEdited synchronously so a concurrent compaction can never mislabel a real edit', async () => { + seedLegacyStream() // The data-loss race: a real edit sits in room.doc synchronously, but if realEdited were set only // AFTER appendUpdate's awaits, a concurrent agent-triggered compaction could snapshot that content and // stamp it an agent (no-persist) frame — losing the edit. The latch must be set in the same tick. const a = await newStore() const doc = new Y.Doc() await a.attachRoom(NAME, doc) - const room = (a as any).rooms.get(NAME) + const room = storeInternals(a).rooms.get(NAME)! expect(room.realEdited).toBe(false) // Kick off a real (non-agent) append but do NOT await it: realEdited must already be true before the // xAdd/expire awaits resolve, so any compaction racing on the awaits sees the real edit. - const pending = (a as any).appendUpdate(NAME, updateFor('real user edit')) + const pending = storeInternals(a).appendUpdate(NAME, updateFor('real user edit')) expect(room.realEdited).toBe(true) await pending doc.destroy() }) + it('compacts a burst of large edits below the entry threshold without losing content', async () => { + seedLegacyStream() + const store = await newStore() + const doc = new Y.Doc() + await store.attachRoom(NAME, doc) + const source = new Y.Doc() + for (let index = 0; index < 4; index++) { + const before = Y.encodeStateVector(source) + source.getText('body').insert(0, 'x'.repeat(3 * 1024 * 1024)) + await store.publishAndWait(NAME, Y.encodeStateAsUpdate(source, before)) + } + + await vi.waitFor(() => { + const stream = state.backing!.streams.get(`filedoc:stream:${NAME}`)! + expect(stream.length).toBeLessThan(400) + expect(stream.some((entry) => entry.message.c === '1')).toBe(true) + }) + const rebuilt = new Y.Doc() + Y.applyUpdate(rebuilt, (await store.getStreamState(NAME))!) + expect(rebuilt.getText('body').length).toBe(12 * 1024 * 1024) + store.detachRoom(NAME) + rebuilt.destroy() + source.destroy() + doc.destroy() + }) + + it('does not append a full snapshot per small edit after growing beyond the byte threshold', async () => { + seedLegacyStream() + const store = await newStore() + const doc = new Y.Doc() + await store.attachRoom(NAME, doc) + const source = new Y.Doc() + for (let index = 0; index < 33; index++) { + const before = Y.encodeStateVector(source) + source.getText('body').insert(0, index < 3 ? 'x'.repeat(3 * 1024 * 1024) : 'tiny') + await store.publishAndWait(NAME, Y.encodeStateAsUpdate(source, before)) + await store.catchUp(NAME) + } + const streamKey = `filedoc:stream:${NAME}` + await vi.waitFor(() => { + expect(storeInternals(store).rooms.get(NAME)!.compacting).toBe(false) + expect(state.backing!.streams.get(streamKey)!.some((entry) => entry.message.c === '1')).toBe( + true + ) + }) + expect( + state.backing!.streams.get(streamKey)!.filter((entry) => entry.message.c === '1').length + ).toBeLessThanOrEqual(2) + const rebuilt = new Y.Doc() + Y.applyUpdate(rebuilt, (await store.getStreamState(NAME))!) + expect(rebuilt.getText('body').length).toBe(9 * 1024 * 1024 + 30 * 4) + expect(rebuilt.getText('body').toString().startsWith('tiny')).toBe(true) + store.detachRoom(NAME) + source.destroy() + rebuilt.destroy() + doc.destroy() + }) + + it.each([false, true])( + 'adopts and compacts an oversized legacy stream (agent: %s)', + async (agent) => { + const source = new Y.Doc() + const updates: Uint8Array[] = [] + source.on('update', (update: Uint8Array) => updates.push(update)) + source.getText('body').insert(0, 'x'.repeat(7 * 1024 * 1024)) + source.getText('body').insert(0, 'tail') + const streamKey = `filedoc:stream:${NAME}` + state.backing!.streams.set( + streamKey, + updates.map((update, index) => ({ + id: `${index + 1}-0`, + message: { u: Buffer.from(update).toString('base64'), ...(agent ? { a: '1' } : {}) }, + })) + ) + state.backing!.seq = updates.length + const store = await newStore() + const doc = new Y.Doc() + await store.attachRoom(NAME, doc) + + await vi.waitFor(() => + expect( + state.backing!.streams.get(streamKey)!.some((entry) => entry.message.c === '1') + ).toBe(true) + ) + const rebuilt = new Y.Doc() + Y.applyUpdate(rebuilt, (await store.getStreamState(NAME))!) + expect(rebuilt.getText('body').length).toBe(7 * 1024 * 1024 + 4) + store.detachRoom(NAME) + rebuilt.destroy() + source.destroy() + doc.destroy() + } + ) + + it('keeps failed compaction accounting armed without repeated snapshot appends', async () => { + seedLegacyStream() + const store = await newStore() + const doc = new Y.Doc() + await store.attachRoom(NAME, doc) + const room = storeInternals(store).rooms.get(NAME)! + room.uncompactedDeltaBytes = 9 * 1024 * 1024 + state.backing!.failSnapshotTrim = true + + await storeInternals(store).maybeCompact(NAME) + + expect(room.uncompactedDeltaBytes).toBe(9 * 1024 * 1024) + expect(room.compactRetryAfter).toBeGreaterThan(Date.now()) + const entriesAfterFailure = state.backing!.streams.get(`filedoc:stream:${NAME}`)!.length + await storeInternals(store).maybeCompact(NAME) + await storeInternals(store).maybeCompact(NAME) + expect(state.backing!.streams.get(`filedoc:stream:${NAME}`)).toHaveLength(entriesAfterFailure) + + state.backing!.failSnapshotTrim = false + room.compactRetryAfter = 0 + await storeInternals(store).maybeCompact(NAME) + expect(room.uncompactedDeltaBytes).toBeLessThan(9 * 1024 * 1024) + store.detachRoom(NAME) + doc.destroy() + }) + + it('retains the last delta without repeatedly folding an unreclaimable boundary', async () => { + seedLegacyStream() + const store = await newStore() + const doc = new Y.Doc() + await store.attachRoom(NAME, doc) + const room = storeInternals(store).rooms.get(NAME)! + room.uncompactedDeltaBytes = 9 * 1024 * 1024 + room.lastDeltaBytes = room.uncompactedDeltaBytes + await storeInternals(store).maybeCompact(NAME) + expect(state.backing!.streams.get(`filedoc:stream:${NAME}`)).toHaveLength(1) + expect(room.uncompactedDeltaBytes).toBe(9 * 1024 * 1024) + store.detachRoom(NAME) + doc.destroy() + }) + + it('excludes older agent compaction output from delta bytes', async () => { + seedLegacyStream() + const store = await newStore() + const doc = new Y.Doc() + await store.attachRoom(NAME, doc) + const room = storeInternals(store).rooms.get(NAME)! + const priorBytes = room.uncompactedDeltaBytes + const encoded = Buffer.from(updateFor('agent snapshot')).toString('base64') + storeInternals(store).applyEntry(NAME, room, '7-0', { u: encoded, a: '1', c: '1' }) + expect(room.uncompactedDeltaBytes).toBe(priorBytes) + expect(room.lastDeltaBytes).toBe(0) + expect(doc.getText('body').toString()).toBe('agent snapshot') + store.detachRoom(NAME) + doc.destroy() + }) + + it('counts peer deltas once using ordered replay without retaining an entry ledger', async () => { + seedLegacyStream() + const store = await newStore() + const doc = new Y.Doc() + await store.attachRoom(NAME, doc) + const room = storeInternals(store).rooms.get(NAME)! + const priorBytes = room.uncompactedDeltaBytes + const encoded = Buffer.from(updateFor('peer')).toString('base64') + storeInternals(store).applyEntry(NAME, room, '4-0', { u: encoded }) + storeInternals(store).applyEntry(NAME, room, '4-0', { u: encoded }) + storeInternals(store).applyEntry(NAME, room, '3-0', { u: encoded }) + expect(room.uncompactedDeltaBytes).toBe(priorBytes + encoded.length) + expect(room.lastDeltaBytes).toBe(encoded.length) + store.detachRoom(NAME) + doc.destroy() + }) + it('stamps a compaction snapshot of an agent-ONLY stream as an agent frame (never persisted)', async () => { const streamKey = `filedoc:stream:${NAME}` const noop = Buffer.from(Y.encodeStateAsUpdate(new Y.Doc())).toString('base64') @@ -414,14 +1253,18 @@ describe('FileDocStore', () => { state.backing!.seq = 400 const a = await newStore() - ;(a as any).rooms.set(NAME, { + storeInternals(a).rooms.set(NAME, { doc: agentDoc, lastId: '400-0', publishes: 0, + uncompactedDeltaBytes: 0, + lastDeltaBytes: 0, + compactRetryAfter: 0, + compacting: false, seededObserved: true, realEdited: false, }) - await (a as any).maybeCompact(NAME) + await storeInternals(a).maybeCompact(NAME) // The snapshot must carry the AGENT marker, NOT the snapshot marker, so a peer catch-up applies it as // REDIS_AGENT_ORIGIN and never marks the doc edited — the no-persist guarantee survives compaction. @@ -438,6 +1281,7 @@ describe('FileDocStore', () => { }) it('retries a transient append failure so the edit is not lost from the shared log', async () => { + seedLegacyStream() const a = await newStore() state.backing!.failXAdd = 2 // first two xAdd attempts throw; the third must succeed a.publish(NAME, updateFor('resilient')) @@ -452,10 +1296,306 @@ describe('FileDocStore', () => { ) }) + it('deduplicates acknowledged client retries by update id', async () => { + seedLegacyStream() + const store = await newStore() + const update = updateFor('retry-safe') + + await store.publishClientUpdateAndWait(NAME, 'update-1', update) + await store.publishClientUpdateAndWait(NAME, 'update-1', update) + + expect(state.backing!.streams.get(`filedoc:stream:${NAME}`)).toHaveLength(2) + }) + + it('does not drop different payloads that reuse an acknowledged update id', async () => { + seedLegacyStream() + const store = await newStore() + + await store.publishClientUpdateAndWait(NAME, 'update-1', updateFor('first')) + await store.publishClientUpdateAndWait(NAME, 'update-1', updateFor('second')) + + expect(state.backing!.streams.get(`filedoc:stream:${NAME}`)).toHaveLength(3) + }) + + it('uses unambiguous acknowledged-update deduplication keys', async () => { + seedLegacyStream() + const store = await newStore() + + await store.publishClientUpdateAndWait(NAME, 'a', new Uint8Array([0, 98])) + await store.publishClientUpdateAndWait(NAME, 'a\0', new Uint8Array([98])) + + expect(state.backing!.streams.get(`filedoc:stream:${NAME}`)).toHaveLength(3) + }) + + it('bounds acknowledged-update deduplication independently of stream traffic', async () => { + seedLegacyStream() + const store = await newStore() + const update = updateFor('bounded') + + for (let index = 0; index <= 16_384; index += 1) { + await store.publishClientUpdateAndWait(NAME, `update-${index}`, update) + } + + expect(state.backing!.dedupe.get(`filedoc:updates:${NAME}`)).toHaveLength(16_384) + }) + + it('limits every multiplexed read to four streams and one entry per stream', async () => { + const store = await newStore() + const docs = Array.from({ length: 9 }, () => new Y.Doc()) + await Promise.all(docs.map((doc, index) => store.attachRoom(`${NAME}-${index}`, doc))) + state.backing!.maxReadStreams = 0 + state.backing!.maxReadCount = 0 + const readsBefore = state.backing!.reads + + await vi.waitFor(() => expect(state.backing!.reads).toBeGreaterThan(readsBefore)) + + expect(state.backing!.maxReadStreams).toBeLessThanOrEqual(4) + expect(state.backing!.maxReadCount).toBe(1) + docs.forEach((doc, index) => { + store.detachRoom(`${NAME}-${index}`) + doc.destroy() + }) + }) + + it.each(['client', 'server'] as const)( + 'does not delay a durable %s append for pending compaction', + async (publisher) => { + const store = await newStore() + await store.seedIfEmpty(NAME, seedFor('base')) + const doc = new Y.Doc() + await store.attachRoom(NAME, doc) + const room = storeInternals(store).rooms.get(NAME)! + room.publishes = 63 + let finishCompaction!: () => void + const compaction = new Promise((resolve) => { + finishCompaction = resolve + }) + state.backing!.onLength = vi.fn(() => compaction) + const publish = (id: string) => + publisher === 'client' + ? store.publishClientUpdateAndWait(NAME, id, updateFor(id), 'doc-base') + : store.publishAndWait(NAME, updateFor(id), 'doc-base') + let accepted = false + const pending = publish('first').then(() => { + accepted = true + }) + try { + await vi.waitFor(() => expect(accepted).toBe(true)) + expect(room.compacting).toBe(true) + expect(state.backing!.streams.get(`filedoc:stream:${NAME}`)).toHaveLength(2) + room.publishes = 127 + await publish('second') + expect(state.backing!.onLength).toHaveBeenCalledOnce() + expect(state.backing!.streams.get(`filedoc:stream:${NAME}`)).toHaveLength(3) + } finally { + finishCompaction() + await pending + await vi.waitFor(() => expect(room.compacting).toBe(false)) + store.detachRoom(NAME) + doc.destroy() + } + } + ) + + it('compacts on retained bytes before the entry-count threshold can exhaust replay', async () => { + const streamKey = `filedoc:stream:${NAME}` + const snapshot = Buffer.from(Y.encodeStateAsUpdate(new Y.Doc())).toString('base64') + state.backing!.streams.set(streamKey, [{ id: '1-0', message: { u: snapshot } }]) + state.backing!.seq = 1 + const store = await newStore() + storeInternals(store).rooms.set(NAME, { + doc: new Y.Doc(), + lastId: '1-0', + publishes: 0, + uncompactedDeltaBytes: 12 * 1024 * 1024, + lastDeltaBytes: 0, + compactRetryAfter: 0, + compacting: false, + seededObserved: true, + realEdited: true, + }) + + await storeInternals(store).maybeCompact(NAME) + + const stream = state.backing!.streams.get(streamKey)! + expect(stream).toHaveLength(2) + expect(stream.at(-1)?.message.s).toBe('1') + }) + + it('does not compact a large snapshot again while continuing to accept small edits', async () => { + const store = await newStore() + const source = docWithText('x'.repeat(9 * 1024 * 1024)) + source.getMap('config').set('initialContentLoaded', true) + source.getMap('config').set('docId', 'large-document') + const streamKey = `filedoc:stream:${NAME}` + state.backing!.kv.set(`filedoc:generation:${NAME}`, 'large-document') + state.backing!.streams.set(streamKey, [ + { + id: '1-0', + message: { + u: Buffer.from(Y.encodeStateAsUpdate(source)).toString('base64'), + s: '1', + g: 'large-document', + }, + }, + ]) + state.backing!.seq = 1 + const loaded = new Y.Doc() + await store.attachRoom(NAME, loaded) + expect(storeInternals(store).rooms.get(NAME)!.uncompactedDeltaBytes).toBe(0) + + let deltaBytes = 0 + for (let index = 0; index < 30; index++) { + const before = Y.encodeStateVector(source) + source.getText('body').insert(source.getText('body').length, 'y') + const update = Y.encodeStateAsUpdate(source, before) + deltaBytes += Buffer.from(update).toString('base64').length + await store.publishClientUpdateAndWait(NAME, `small-${index}`, update, 'large-document') + await store.catchUp(NAME) + } + expect(state.backing!.streams.get(streamKey)?.filter((entry) => entry.message.s)).toHaveLength( + 1 + ) + expect(storeInternals(store).rooms.get(NAME)!.uncompactedDeltaBytes).toBe(deltaBytes) + expect(loaded.getText('body').length).toBe(9 * 1024 * 1024 + 30) + store.detachRoom(NAME) + source.destroy() + loaded.destroy() + }) + + it('preserves the inclusive barrier and delta bytes observed during compaction', async () => { + seedLegacyStream() + const store = await newStore() + const doc = new Y.Doc() + await store.attachRoom(NAME, doc) + const room = storeInternals(store).rooms.get(NAME)! + room.uncompactedDeltaBytes = 12 * 1024 * 1024 + const retainedBarrierBytes = room.lastDeltaBytes + const lateUpdate = updateFor('concurrent edit') + state.backing!.onSnapshot = async () => { + await store.publishAndWait(NAME, lateUpdate) + await store.catchUp(NAME) + } + await storeInternals(store).maybeCompact(NAME) + expect(room.uncompactedDeltaBytes).toBe( + retainedBarrierBytes + Buffer.from(lateUpdate).toString('base64').length + ) + expect(doc.getText('body').toString()).toBe('concurrent edit') + store.detachRoom(NAME) + doc.destroy() + }) + + it('does not trim a replacement stream recreated in the same millisecond as its compaction barrier', async () => { + const store = await newStore() + const replacer = await newStore() + const oldDoc = docWithText('old') + oldDoc.getMap('config').set('docId', 'old-generation') + state.backing!.nextIds = ['1000-0', '1000-1', '1000-2'] + await store.seedIfEmpty(NAME, Y.encodeStateAsUpdate(oldDoc), 10) + await store.publishAndWait(NAME, updateFor('first edit'), 'old-generation') + await store.publishAndWait(NAME, updateFor('second edit'), 'old-generation') + await store.attachRoom(NAME, oldDoc) + storeInternals(store).rooms.get(NAME)!.uncompactedDeltaBytes = 12 * 1024 * 1024 + + const freshDoc = docWithText('fresh') + freshDoc.getMap('config').set('docId', 'new-generation') + const freshSeed = Y.encodeStateAsUpdate(freshDoc) + const beforeEdit = Y.encodeStateVector(freshDoc) + freshDoc.getText('body').insert(5, ' accepted edit') + const freshEdit = Y.encodeStateAsUpdate(freshDoc, beforeEdit) + const streamKey = `filedoc:stream:${NAME}` + state.backing!.nextIds = ['1000-3', '1000-0', '1000-1'] + state.backing!.onSnapshot = async () => { + await replacer.invalidateDocument(NAME, 20) + await replacer.seedIfEmpty(NAME, freshSeed, 20) + await replacer.publishClientUpdateAndWait(NAME, 'fresh-edit', freshEdit, 'new-generation') + expect(state.backing!.streams.get(streamKey)?.map((entry) => entry.id)).toEqual([ + '1000-0', + '1000-1', + ]) + } + + await storeInternals(store).maybeCompact(NAME) + + expect(state.backing!.streams.get(streamKey)?.map((entry) => entry.id)).toEqual([ + '1000-0', + '1000-1', + ]) + await replacer.publishClientUpdateAndWait(NAME, 'fresh-edit', freshEdit, 'new-generation') + expect(state.backing!.streams.get(streamKey)).toHaveLength(2) + const persisted = await replacer.getStreamState(NAME) + expect(persisted).not.toBeNull() + const replayed = new Y.Doc() + Y.applyUpdate(replayed, persisted!) + expect(replayed.getText('body').toString()).toBe('fresh accepted edit') + store.detachRoom(NAME) + oldDoc.destroy() + freshDoc.destroy() + replayed.destroy() + }) + + it('expires idle single-replica invalidation watermarks', async () => { + vi.useFakeTimers() + const store = new FileDocStore(undefined) + try { + for (let index = 0; index < 100; index++) { + await store.invalidateDocument(`closed-${index}`, 10) + } + expect(storeInternals(store).localInvalidations.size).toBe(100) + await vi.advanceTimersByTimeAsync(660_000) + expect(storeInternals(store).localInvalidations.size).toBe(0) + } finally { + await store.shutdown() + vi.useRealTimers() + } + }) + + it('deduplicates single-replica invalidations across same-version seeds and room reopen', async () => { + const store = new FileDocStore(undefined) + const staleDoc = new Y.Doc() + await store.attachRoom(NAME, staleDoc) + await store.invalidateDocument(NAME, 20) + await expect(store.seedIfEmpty(NAME, seedFor('stale fetched seed'), 10)).resolves.toBe(false) + await expect(store.isDocumentGenerationCurrent(NAME)).resolves.toBe(false) + expect(storeInternals(store).localInvalidations.size).toBe(1) + await expect(store.seedIfEmpty(NAME, seedFor('same-version seed'), 20)).resolves.toBe(true) + await expect(store.invalidateDocument(NAME, 20)).resolves.toEqual({ status: 'stale' }) + await expect(store.isDocumentGenerationCurrent(NAME)).resolves.toBe(true) + + store.detachRoom(NAME) + expect(storeInternals(store).localInvalidations.size).toBe(1) + const freshDoc = new Y.Doc() + await store.attachRoom(NAME, freshDoc) + await expect(store.seedIfEmpty(NAME, seedFor('fresh authoritative seed'), 20)).resolves.toBe( + true + ) + await expect(store.invalidateDocument(NAME, 20)).resolves.toEqual({ status: 'stale' }) + await expect(store.isDocumentGenerationCurrent(NAME)).resolves.toBe(true) + await store.invalidateDocument(NAME, 40) + await store.shutdown() + expect(storeInternals(store).localInvalidations.size).toBe(0) + staleDoc.destroy() + freshDoc.destroy() + }) + + it('fails closed when a Redis-backed store has not initialized', async () => { + const store = new FileDocStore(REDIS_URL) + const doc = new Y.Doc() + + await expect(store.attachRoom(NAME, doc)).rejects.toThrow('not initialized') + await expect( + store.publishClientUpdateAndWait(NAME, 'update-1', updateFor('x')) + ).rejects.toThrow('not initialized') + await expect(store.seedIfEmpty(NAME, seedFor('seed'))).rejects.toThrow('not initialized') + await expect(store.getStreamState(NAME)).rejects.toThrow('not initialized') + expect(await store.acquireMergeSlot(NAME, 1_000)).toBeNull() + doc.destroy() + }) + it('streamHasContent fences a seed apply against an already-seeded stream', async () => { const a = await newStore() expect(await a.streamHasContent(NAME)).toBe(false) - a.publish(NAME, updateFor('seeded')) + await a.seedIfEmpty(NAME, seedFor('seeded')) await vi.waitFor(async () => expect(await a.streamHasContent(NAME)).toBe(true)) }) @@ -488,12 +1628,60 @@ describe('FileDocStore', () => { doc.destroy() }) + it.each([ + { redis: true, docId: undefined }, + { redis: true, docId: '' }, + { redis: false, docId: undefined }, + { redis: false, docId: '' }, + ])('rejects an unnamed seed before publication (%j)', async ({ redis, docId }) => { + const store = redis ? await newStore() : new FileDocStore(undefined) + const doc = new Y.Doc() + const config = doc.getMap(FILE_DOC_SEED.configMap) + config.set(FILE_DOC_SEED.flag, true) + if (docId !== undefined) config.set(FILE_DOC_SEED.docIdKey, docId) + try { + await expect(store.seedIfEmpty(NAME, Y.encodeStateAsUpdate(doc), 1)).rejects.toThrow( + 'missing its accepted document identity' + ) + await expect(store.getStreamState(NAME)).resolves.toBeNull() + } finally { + doc.destroy() + if (!redis) await store.shutdown() + } + }) + + it('uses the same accepted identity for the seed owner and a replaying peer', async () => { + const owner = await newStore() + const peer = await newStore() + const ownerDoc = new Y.Doc() + const peerDoc = new Y.Doc() + try { + await owner.attachRoom(NAME, ownerDoc) + const seed = seedFor('shared identity') + expect(await owner.seedIfEmpty(NAME, seed, 1)).toBe(true) + Y.applyUpdate(ownerDoc, seed) + await peer.attachRoom(NAME, peerDoc) + for (const [store, doc] of [ + [owner, ownerDoc], + [peer, peerDoc], + ] as const) { + const docId = doc.getMap(FILE_DOC_SEED.configMap).get(FILE_DOC_SEED.docIdKey) + expect(docId).toBe('doc-shared identity') + expect(await store.getDocumentGeneration(NAME)).toBe(docId) + expect(await store.isDocumentGenerationCurrent(NAME, 'doc-shared identity')).toBe(true) + } + } finally { + ownerDoc.destroy() + peerDoc.destroy() + } + }) + it('seedIfEmpty writes the seed once and reports it, then refuses a non-empty stream', async () => { const a = await newStore() - expect(await a.seedIfEmpty(NAME, updateFor('first'))).toBe(true) + expect(await a.seedIfEmpty(NAME, seedFor('first'))).toBe(true) // A second seed attempt (any task) must be refused — the stream already holds content. const b = await newStore() - expect(await b.seedIfEmpty(NAME, updateFor('second'))).toBe(false) + expect(await b.seedIfEmpty(NAME, seedFor('second'))).toBe(false) const doc = new Y.Doc() Y.applyUpdate(doc, (await a.getStreamState(NAME))!) expect(doc.getText('body').toString()).toBe('first') @@ -515,8 +1703,8 @@ describe('FileDocStore', () => { expect(tokenB).toBeTruthy() // Both tasks now race to seed with distinct client ids. const [seededA, seededB] = await Promise.all([ - a.seedIfEmpty(NAME, updateFor('SEED-A')), - b.seedIfEmpty(NAME, updateFor('SEED-B')), + a.seedIfEmpty(NAME, seedFor('SEED-A')), + b.seedIfEmpty(NAME, seedFor('SEED-B')), ]) expect([seededA, seededB].filter(Boolean)).toHaveLength(1) // Exactly one seed is in the stream — the reconstructed text is a single seed, never a duplicated @@ -536,7 +1724,7 @@ describe('FileDocStore', () => { author.getText('body').insert(4, 'peer') const a = await newStore() - a.publish(NAME, updates[0]) // 'base' + seedLegacyStream(updates[0]) await vi.waitFor(async () => expect(await a.getStreamState(NAME)).not.toBeNull()) // Task B attaches; while its synchronous catch-up runs, task A publishes the second edit. The tailer @@ -580,21 +1768,29 @@ describe('FileDocStore', () => { const b = await newStore() const docA = new Y.Doc() Y.applyUpdate(docA, peerUpdates[0]) // A integrated up to 401 - ;(a as any).rooms.set(NAME, { + storeInternals(a).rooms.set(NAME, { doc: docA, lastId: '401-0', publishes: 0, + uncompactedDeltaBytes: 0, + lastDeltaBytes: 0, + compactRetryAfter: 0, + compacting: false, seededObserved: true, realEdited: true, }) - ;(b as any).rooms.set(NAME, { + storeInternals(b).rooms.set(NAME, { doc: new Y.Doc(), lastId: '400-0', publishes: 0, + uncompactedDeltaBytes: 0, + lastDeltaBytes: 0, + compactRetryAfter: 0, + compacting: false, seededObserved: true, realEdited: true, }) - await Promise.all([(a as any).maybeCompact(NAME), (b as any).maybeCompact(NAME)]) + await Promise.all([storeInternals(a).maybeCompact(NAME), storeInternals(b).maybeCompact(NAME)]) const doc = new Y.Doc() Y.applyUpdate(doc, (await a.getStreamState(NAME))!) diff --git a/apps/realtime/src/handlers/file-doc-store.ts b/apps/realtime/src/handlers/file-doc-store.ts index 537f7f4db12..3fa761671ed 100644 --- a/apps/realtime/src/handlers/file-doc-store.ts +++ b/apps/realtime/src/handlers/file-doc-store.ts @@ -34,8 +34,10 @@ * * @module */ + +import { createHash } from 'node:crypto' import { createLogger } from '@sim/logger' -import { FILE_DOC_SEED, FILE_DOC_TIMEOUTS } from '@sim/realtime-protocol/file-doc' +import { FILE_DOC_LIMITS, FILE_DOC_SEED, FILE_DOC_TIMEOUTS } from '@sim/realtime-protocol/file-doc' import { getErrorMessage } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' import { generateId } from '@sim/utils/id' @@ -61,7 +63,38 @@ const RELEASE_LOCK_SCRIPT = * Returns 1 if THIS call wrote the seed, 0 if the stream already had content. */ const SEED_IF_EMPTY_SCRIPT = - "if redis.call('xlen', KEYS[1]) == 0 then redis.call('xadd', KEYS[1], '*', ARGV[1], ARGV[2]); return 1 else return 0 end" + "local version = redis.call('get', KEYS[3]); if version and tonumber(version) > tonumber(ARGV[6]) then return 0 end; if redis.call('xlen', KEYS[1]) == 0 then redis.call('set', KEYS[2], ARGV[3], 'EX', ARGV[4]); if ARGV[6] ~= '0' then redis.call('set', KEYS[3], ARGV[6], 'EX', ARGV[4]) end; redis.call('xadd', KEYS[1], '*', ARGV[1], ARGV[2], ARGV[5], ARGV[3]); redis.call('expire', KEYS[1], ARGV[4]); redis.call('expire', KEYS[4], ARGV[4]); return 1 else return 0 end" + +/** Orders a durable replacement with seeds and merges, and fences publishers in the same transaction. */ +const INVALIDATE_DOCUMENT_SCRIPT = + "local invalidated = redis.call('get', KEYS[6]); if invalidated and tonumber(invalidated) >= tonumber(ARGV[1]) then return false end; local version = redis.call('get', KEYS[3]); if version and tonumber(version) > tonumber(ARGV[1]) then return false end; local generation = redis.call('get', KEYS[2]) or ''; if version == ARGV[1] and generation == ARGV[3] then return false end; redis.call('set', KEYS[2], ARGV[3], 'EX', ARGV[2]); redis.call('set', KEYS[3], ARGV[1], 'EX', ARGV[2]); redis.call('set', KEYS[6], ARGV[1], 'EX', ARGV[2]); redis.call('del', KEYS[1], KEYS[4], KEYS[5]); return generation" + +/** Upgrades an existing pre-negotiation stream without ever resurrecting a missing stream. */ +const ADOPT_GENERATION_SCRIPT = + "local generation = redis.call('get', KEYS[2]); if generation then return generation end; if redis.call('xlen', KEYS[1]) == 0 then return false end; redis.call('set', KEYS[2], ARGV[1], 'EX', ARGV[2]); return ARGV[1]" + +/** Atomically fence XADD so stale rooms cannot recreate a replaced or expired stream. Returns false when fenced. */ +const APPEND_UPDATE_SCRIPT = + "local generation = redis.call('get', KEYS[2]) or ''; if generation ~= ARGV[4] or redis.call('exists', KEYS[1]) == 0 then return false end; for _, key in ipairs(KEYS) do redis.call('expire', key, ARGV[5]) end; if ARGV[3] ~= '' then return redis.call('xadd', KEYS[1], '*', ARGV[1], ARGV[2], ARGV[3], '1') else return redis.call('xadd', KEYS[1], '*', ARGV[1], ARGV[2]) end" + +/** Renew stream metadata atomically, so an invalidation watermark cannot expire ahead of its stream. */ +const REFRESH_DOCUMENT_TTLS_SCRIPT = + "for _, key in ipairs(KEYS) do redis.call('expire', key, ARGV[1]) end; return 1" + +/** + * Append and trim under one generation fence: invalidation can recreate the stream with lower IDs + * within the same millisecond, so a separate trim could delete the replacement's seed and edits. + * Carry the seed's generation forward and keep every entry at or beyond the captured prefix barrier. + */ +const APPEND_SNAPSHOT_SCRIPT = + "local generation = redis.call('get', KEYS[2]) or ''; if generation ~= ARGV[4] or redis.call('exists', KEYS[1]) == 0 then return false end; local id = redis.call('xadd', KEYS[1], '*', ARGV[1], ARGV[2], ARGV[3], '1', ARGV[5], ARGV[4], ARGV[7], '1'); redis.call('xtrim', KEYS[1], 'MINID', ARGV[6]); return id" + +/** + * Atomically deduplicate and append an acknowledged client update. Socket acknowledgements can be + * lost, so a retry with the same id must not inflate the stream or its compaction counters. + */ +const APPEND_CLIENT_UPDATE_SCRIPT = + "local generation = redis.call('get', KEYS[3]) or ''; if generation ~= ARGV[6] or redis.call('exists', KEYS[1]) == 0 then return -1 end; redis.call('expire', KEYS[4], ARGV[5]); if redis.call('zscore', KEYS[2], ARGV[1]) then redis.call('expire', KEYS[1], ARGV[5]); redis.call('expire', KEYS[3], ARGV[5]); return 0 end; local id = redis.call('xadd', KEYS[1], '*', ARGV[2], ARGV[3]); local score = string.match(id, '^(%d+)'); redis.call('zadd', KEYS[2], score, ARGV[1]); local excess = redis.call('zcard', KEYS[2]) - tonumber(ARGV[4]); if excess > 0 then redis.call('zpopmin', KEYS[2], excess) end; if redis.call('ttl', KEYS[2]) < 0 then redis.call('expire', KEYS[2], ARGV[5]) end; redis.call('expire', KEYS[1], ARGV[5]); redis.call('expire', KEYS[3], ARGV[5]); return 1" /** * Monotonic set of the synced-version token: overwrite ONLY when the new value is greater than the @@ -73,7 +106,7 @@ const SEED_IF_EMPTY_SCRIPT = * comfortably within a Lua double, so the numeric compare is exact. */ const SET_VERSION_IF_NEWER_SCRIPT = - "local c = redis.call('get', KEYS[1]); if c == false or tonumber(c) < tonumber(ARGV[1]) then redis.call('set', KEYS[1], ARGV[1], 'EX', ARGV[2]) else redis.call('expire', KEYS[1], ARGV[2]) end; return 1" + "local generation = redis.call('get', KEYS[2]) or ''; if generation ~= ARGV[3] then return 0 end; local c = redis.call('get', KEYS[1]); if c == false or tonumber(c) < tonumber(ARGV[1]) then redis.call('set', KEYS[1], ARGV[1], 'EX', ARGV[2]) else redis.call('expire', KEYS[1], ARGV[2]) end; return 1" /** * The transaction origin the store stamps on updates it applies from the stream. The relay's @@ -103,6 +136,10 @@ export const REDIS_SNAPSHOT_ORIGIN = Symbol('file-doc-redis-snapshot') export const REDIS_AGENT_ORIGIN = Symbol('file-doc-redis-agent') const STREAM_PREFIX = 'filedoc:stream:' +const CLIENT_UPDATE_PREFIX = 'filedoc:updates:' +const GENERATION_PREFIX = 'filedoc:generation:' +/** Retries must remain idempotent after a seed replaces the generation tombstone. */ +const INVALIDATION_VERSION_PREFIX = 'filedoc:invalidatedver:' /** Cluster-wide "durable version the live doc is synced to" (the persist If-Match token). */ const SYNC_VERSION_PREFIX = 'filedoc:syncver:' const SEED_LOCK_PREFIX = 'filedoc:seedlock:' @@ -123,6 +160,11 @@ const SNAPSHOT_FIELD = 's' /** Marks a stream entry as an AGENT-STREAMED preview frame, so the tailer applies it with * {@link REDIS_AGENT_ORIGIN} (never marks the doc edited). Present only on agent-frame entries. */ const AGENT_FIELD = 'a' +/** Distinguishes compacted agent snapshots from ordinary agent deltas, including older writers. */ +const COMPACTION_FIELD = 'c' +/** Identifies a seed's document generation, allowing old rooms to reject every later update. */ +const GENERATION_FIELD = 'g' +const INVALIDATED_GENERATION = '__invalidated__' /** Sentinel token a DISABLED store returns from a lock acquire, so single-replica callers proceed * without special-casing; {@link FileDocStore.releaseLock} treats it as a no-op. Not a real UUID, so it @@ -136,8 +178,18 @@ const READ_BLOCK_MS = 1_000 /** Idle poll cadence when NO room is open on this task, so a freshly-attached room is picked up fast * without busy-spinning an empty task. */ const IDLE_POLL_MS = 250 -/** Max entries drained per stream per read. */ -const READ_COUNT = 200 +/** Max entries drained per stream per read, bounding one Redis response even for maximum-size edits. */ +const READ_COUNT = 1 +/** Maximum streams passed to one XREAD, bounding response memory independently of open-room count. */ +const READ_STREAM_BATCH_SIZE = 4 +/** Replay streams incrementally instead of materializing their complete history in one response. */ +const REPLAY_PAGE_COUNT = 4 +/** Compaction normally holds a stream near 400 entries; fail safely if that invariant is badly broken. */ +const REPLAY_MAX_ENTRIES = 2_000 +/** Base64 bytes accepted during one replay, including a full snapshot plus a bounded edit backlog. */ +const REPLAY_MAX_ENCODED_BYTES = FILE_DOC_LIMITS.updateBytes * 6 +/** Compact before a handful of individually valid large updates can exhaust the replay byte budget. */ +const COMPACT_ENCODED_BYTES = 8 * 1024 * 1024 /** Compact a stream once it exceeds this many entries (snapshot + trim). */ const COMPACT_THRESHOLD = 400 /** Check whether compaction is due only every Nth local publish, to avoid an XLEN per keystroke. */ @@ -145,6 +197,8 @@ const COMPACT_CHECK_EVERY = 64 /** Compaction critical section (snapshot + xAdd + xTrim) is fast; a generous TTL covers a slow Redis * round-trip without risking expiry mid-compact. Released via compare-and-delete regardless. */ const COMPACT_LOCK_TTL_MS = 10_000 +/** Avoid repeated snapshot appends when a failed compaction leaves the byte trigger armed. */ +const COMPACT_RETRY_COOLDOWN_MS = 30_000 /** Retry a failed stream append this many times before giving up, so a transient Redis blip doesn't * silently drop an edit from the shared log (which no peer would then ever see). */ const PUBLISH_MAX_RETRIES = 3 @@ -166,8 +220,43 @@ const RECONNECT_MAX_DELAY_MS = 3_000 const READER_RETRY_MAX_MS = 10_000 /** After the first failure of a streak, log one reader failure in this many. */ const READER_ERROR_LOG_EVERY = 20 +const CLIENT_UPDATE_DEDUPE_CAPACITY = 16_384 const streamKey = (name: string) => `${STREAM_PREFIX}${name}` +const generationKey = (name: string) => `${GENERATION_PREFIX}${name}` +const documentKeys = (name: string) => [ + streamKey(name), + generationKey(name), + `${SYNC_VERSION_PREFIX}${name}`, + `${INVALIDATION_VERSION_PREFIX}${name}`, +] + +export class FileDocInvalidatedError extends Error { + constructor() { + super('The live file document was replaced by a newer durable version') + this.name = 'FileDocInvalidatedError' + } +} + +function assertUpdateWithinLimit(update: Uint8Array): void { + if (update.byteLength === 0 || update.byteLength > FILE_DOC_LIMITS.updateBytes) { + throw new Error(`File document update is outside the ${FILE_DOC_LIMITS.updateBytes}-byte limit`) + } +} + +function generationOfSeed(update: Uint8Array): string { + const doc = new Y.Doc() + try { + Y.applyUpdate(doc, update) + const docId = doc.getMap(FILE_DOC_SEED.configMap).get(FILE_DOC_SEED.docIdKey) + if (typeof docId !== 'string' || docId.length === 0) { + throw new Error('File document seed is missing its accepted document identity') + } + return docId + } finally { + doc.destroy() + } +} /** * Decode one stream entry's base64 Yjs update and apply it to `doc`. A malformed entry is logged and @@ -218,6 +307,16 @@ interface StoreRoom { lastId: string /** Local publish count, to pace compaction checks. */ publishes: number + /** Non-snapshot bytes observed since this replica last compacted. */ + uncompactedDeltaBytes: number + /** MINID retains the last applied entry; its bytes cannot trigger a fold until the cursor advances. */ + lastDeltaBytes: number + compactRetryAfter: number + compacting: boolean + /** Document generation read from the seed entry; every later append is fenced against it. */ + generation: string | null + /** A newer seed was observed; this old room must ignore all entries until the relay replaces it. */ + generationInvalidated: boolean /** Set once the doc has been observed seeded, so the seed transition itself is never mistaken for an * edit (mirrors the relay's `seededObserved`). */ seededObserved: boolean @@ -239,6 +338,7 @@ export class FileDocStore { /** Dedicated connection for blocking XREAD (a blocking command monopolizes its connection). */ private read: RedisClientType | null = null private readonly rooms = new Map() + private readonly localInvalidations = new Map() private running = false private heartbeat: ReturnType | null = null @@ -262,7 +362,10 @@ export class FileDocStore { * connection it can rebuild is always worth rebuilding. */ reconnectStrategy: (retries: number) => - backoffWithJitter(retries + 1, null, { baseMs: 100, maxMs: RECONNECT_MAX_DELAY_MS }), + backoffWithJitter(retries + 1, null, { + baseMs: 100, + maxMs: RECONNECT_MAX_DELAY_MS, + }), }, } this.write = createClient(options) @@ -284,55 +387,78 @@ export class FileDocStore { await Promise.all([this.write?.quit().catch(() => {}), this.read?.quit().catch(() => {})]) this.write = null this.read = null + this.rooms.clear() + this.localInvalidations.clear() } /** * Register a locally-opened room and load the shared state into its doc ({@link catchUp}). A * brand-new file has an empty stream and loads nothing (it is seeded shortly after, via - * {@link shouldSeed}). No-op when disabled. + * {@link shouldSeed}). Single-replica rooms are tracked only for invalidation lifecycle. */ async attachRoom(name: string, doc: Y.Doc): Promise { - if (!this.enabled || !this.write) return + if (this.enabled && !this.write) throw new Error('FileDocStore is not initialized') // Register BEFORE the async read so a concurrent publish/tailer for this room can't be missed — // the tailer resumes from `lastId`, which the catch-up advances. const room: StoreRoom = { doc, lastId: '0', publishes: 0, + uncompactedDeltaBytes: 0, + lastDeltaBytes: 0, + compactRetryAfter: 0, + compacting: false, + generation: null, + generationInvalidated: false, seededObserved: false, realEdited: false, } this.rooms.set(name, room) - await this.catchUp(name) + if (!this.enabled) return + try { + await this.catchUp(name) + } catch (error) { + if (this.rooms.get(name) === room) this.rooms.delete(name) + throw error + } } /** - * PULL the shared state into a registered room: read the stream and apply every entry the doc has - * not integrated yet (origin {@link REDIS_ORIGIN}), advancing `lastId` so the tailer resumes exactly - * after it. This is the ONLY way a room loads shared state, so a caller that must not depend on the - * tailer's asynchronous push — the join, which may not serve a client a half-assembled document — - * can converge on demand. Idempotent and safe to call repeatedly; no-op when disabled or the room is - * not registered (a fast open→close detached it). Never throws. + * Completes shared replay before applying entries, so joins never receive a partial document. + * Repeated calls skip integrated entries; detached rooms and disabled stores are ignored. */ async catchUp(name: string): Promise { - if (!this.enabled || !this.write) return + if (!this.enabled) return + if (!this.write) throw new Error('FileDocStore is not initialized') const room = this.rooms.get(name) if (!room) return try { - const entries = await this.write.xRange(streamKey(name), '-', '+') - for (const entry of entries) { + const entries: Array<{ id: string; message: Record }> = [] + await this.replayEntries(name, room.lastId, (entry) => { // The room can be detached + its doc destroyed while the read is in flight (a fast // open→close); stop touching it the moment that happens. - if (this.rooms.get(name) !== room) return - // Applying a Yjs update twice is a no-op, but `applyEntry`'s bookkeeping is not: re-applying - // the SEED after `seededObserved` latched would count it as a post-seed edit and let a - // compaction snapshot claim content no user ever typed. Skip what this room already holds. - if (!isAfterStreamId(entry.id, room.lastId)) continue - this.applyEntry(room, entry.id, entry.message) + if (this.rooms.get(name) !== room) return false + entries.push(entry) + return true + }) + if (this.rooms.get(name) !== room) return + for (const entry of entries) this.applyEntry(name, room, entry.id, entry.message) + if (room.generationInvalidated) throw new FileDocInvalidatedError() + const docId = room.doc.getMap(FILE_DOC_SEED.configMap).get(FILE_DOC_SEED.docIdKey) + if (room.generation === null && typeof docId === 'string') { + const adopted = await this.write.eval(ADOPT_GENERATION_SCRIPT, { + keys: [streamKey(name), generationKey(name)], + arguments: [docId, String(STREAM_TTL_SEC)], + }) + if (adopted !== docId) throw new FileDocInvalidatedError() + room.generation = docId } - await this.write.expire(streamKey(name), STREAM_TTL_SEC) + await this.refreshDocumentTtls(name) } catch (error) { - logger.warn(`FileDocStore catch-up failed for ${name}`, { error: getErrorMessage(error) }) + logger.warn(`FileDocStore catch-up failed for ${name}`, { + error: getErrorMessage(error), + }) + throw error } } @@ -344,11 +470,17 @@ export class FileDocStore { /** * Append a locally-applied update to the shared stream so every task converges, AWAITING the write * and retrying a transient failure ({@link PUBLISH_MAX_RETRIES}) so a Redis blip can't silently drop - * an edit from the shared log. Only the `xAdd` is retried; the TTL refresh + compaction check are - * post-write best-effort and never re-trigger the append. Throws if the append ultimately fails. + * an edit from the shared log. The append and metadata TTL renewal are atomic; post-write + * compaction never re-triggers the append. Throws if the append ultimately fails. */ - private async appendUpdate(name: string, update: Uint8Array, agent = false): Promise { + private async appendUpdate( + name: string, + update: Uint8Array, + agent = false, + expectedGeneration = this.rooms.get(name)?.generation ?? '' + ): Promise { if (!this.write) return + assertUpdateWithinLimit(update) // Latch realEdited SYNCHRONOUSLY — before the first await — for a real (non-agent) publish. The edit // already sits in room.doc (applied in doc.on('update') before publish was called), so if this set // were deferred past the xAdd/expire awaits a CONCURRENT agent-frame-triggered maybeCompact could read @@ -361,15 +493,21 @@ export class FileDocStore { if (editedRoom) editedRoom.realEdited = true } const encoded = Buffer.from(update).toString('base64') - const fields: Record = { [UPDATE_FIELD]: encoded } - if (agent) fields[AGENT_FIELD] = '1' + const marker = agent ? AGENT_FIELD : '' for (let attempt = 0; attempt <= PUBLISH_MAX_RETRIES; attempt++) { try { - await this.write.xAdd(streamKey(name), '*', fields) + const id = await this.write.eval(APPEND_UPDATE_SCRIPT, { + keys: documentKeys(name), + arguments: [UPDATE_FIELD, encoded, marker, expectedGeneration, String(STREAM_TTL_SEC)], + }) + if (id === null || id === false) throw new FileDocInvalidatedError() break } catch (error) { + if (error instanceof FileDocInvalidatedError) throw error if (attempt === PUBLISH_MAX_RETRIES) { - logger.error(`FileDocStore append failed for ${name}`, { error: getErrorMessage(error) }) + logger.error(`FileDocStore append failed for ${name}`, { + error: getErrorMessage(error), + }) throw error } // Snappy backoff — a stream append is a fast op; a transient blip clears in tens of ms. @@ -377,9 +515,16 @@ export class FileDocStore { await sleep(backoffWithJitter(attempt + 1, null, { baseMs: 50, maxMs: 500 })) } } - await this.write.expire(streamKey(name), STREAM_TTL_SEC).catch(() => {}) const room = this.rooms.get(name) - if (room && ++room.publishes % COMPACT_CHECK_EVERY === 0) void this.maybeCompact(name) + if (room) { + room.publishes += 1 + if ( + room.uncompactedDeltaBytes - room.lastDeltaBytes >= COMPACT_ENCODED_BYTES || + room.publishes % COMPACT_CHECK_EVERY === 0 + ) { + void this.maybeCompact(name) + } + } } /** @@ -389,7 +534,11 @@ export class FileDocStore { */ publish(name: string, update: Uint8Array, agent = false): void { if (!this.enabled || !this.write) return - void this.appendUpdate(name, update, agent).catch(() => {}) // already logged inside appendUpdate + void this.appendUpdate(name, update, agent).catch((error) => { + logger.warn(`FileDocStore rejected a non-durable legacy update for ${name}`, { + error: getErrorMessage(error), + }) + }) } /** @@ -397,9 +546,80 @@ export class FileDocStore { * — the copilot merge, so the cross-task merge lock is not released before the diff is committed * (else the next task would diff a stale base). Throws on ultimate failure. No-op when disabled. */ - async publishAndWait(name: string, update: Uint8Array): Promise { - if (!this.enabled || !this.write) return - await this.appendUpdate(name, update) + async publishAndWait( + name: string, + update: Uint8Array, + expectedGeneration = this.rooms.get(name)?.generation ?? '' + ): Promise { + if (!this.enabled) return + if (!this.write) throw new Error('FileDocStore is not initialized') + await this.appendUpdate(name, update, false, expectedGeneration) + } + + /** + * Waits for Redis acceptance before the relay acknowledges the client. Retries are deduplicated + * within the bounded window; clients retain their journal until the acknowledgement arrives. + */ + async publishClientUpdateAndWait( + name: string, + updateId: string, + update: Uint8Array, + expectedGeneration = this.rooms.get(name)?.generation ?? '' + ): Promise { + if (!this.enabled) return + if (!this.write) throw new Error('FileDocStore is not initialized') + assertUpdateWithinLimit(update) + const encoded = Buffer.from(update).toString('base64') + const dedupeMember = createHash('sha256') + .update(String(Buffer.byteLength(updateId))) + .update(':') + .update(updateId) + .update(update) + .digest('hex') + const room = this.rooms.get(name) + + for (let attempt = 0; attempt <= PUBLISH_MAX_RETRIES; attempt++) { + try { + const appended = await this.write.eval(APPEND_CLIENT_UPDATE_SCRIPT, { + keys: [ + streamKey(name), + `${CLIENT_UPDATE_PREFIX}${name}`, + generationKey(name), + `${INVALIDATION_VERSION_PREFIX}${name}`, + ], + arguments: [ + dedupeMember, + UPDATE_FIELD, + encoded, + String(CLIENT_UPDATE_DEDUPE_CAPACITY), + String(STREAM_TTL_SEC), + expectedGeneration, + ], + }) + if (appended === -1) throw new FileDocInvalidatedError() + if (appended === 1 && room) { + room.realEdited = true + room.publishes += 1 + if ( + room.uncompactedDeltaBytes - room.lastDeltaBytes >= COMPACT_ENCODED_BYTES || + room.publishes % COMPACT_CHECK_EVERY === 0 + ) { + void this.maybeCompact(name) + } + } + return + } catch (error) { + if (error instanceof FileDocInvalidatedError) throw error + if (attempt === PUBLISH_MAX_RETRIES) { + logger.error(`FileDocStore acknowledged append failed for ${name}`, { + updateId, + error: getErrorMessage(error), + }) + throw error + } + await sleep(backoffWithJitter(attempt + 1, null, { baseMs: 50, maxMs: 500 })) + } + } } /** @@ -412,20 +632,41 @@ export class FileDocStore { * Retries a transient Redis error like {@link appendUpdate}; throws if it ultimately fails. Disabled → * true (single-replica: seed locally, no stream). */ - async seedIfEmpty(name: string, update: Uint8Array): Promise { - if (!this.enabled || !this.write) return true + async seedIfEmpty(name: string, update: Uint8Array, version = 0): Promise { + assertUpdateWithinLimit(update) + const generation = generationOfSeed(update) + if (!this.enabled) { + const invalidation = this.localInvalidations.get(name) + if (invalidation && invalidation.expiresAt > Date.now() && invalidation.version > version) + return false + const room = this.rooms.get(name) + if (room) room.generationInvalidated = false + return true + } + if (!this.write) throw new Error('FileDocStore is not initialized') const encoded = Buffer.from(update).toString('base64') for (let attempt = 0; attempt <= PUBLISH_MAX_RETRIES; attempt++) { try { const wrote = await this.write.eval(SEED_IF_EMPTY_SCRIPT, { - keys: [streamKey(name)], - arguments: [UPDATE_FIELD, encoded], + keys: documentKeys(name), + arguments: [ + UPDATE_FIELD, + encoded, + generation, + String(STREAM_TTL_SEC), + GENERATION_FIELD, + String(version), + ], }) - await this.write.expire(streamKey(name), STREAM_TTL_SEC).catch(() => {}) + await this.refreshDocumentTtls(name).catch(() => {}) + const room = this.rooms.get(name) + if (wrote === 1 && room) room.generation = generation return wrote === 1 } catch (error) { if (attempt === PUBLISH_MAX_RETRIES) { - logger.error(`FileDocStore seed failed for ${name}`, { error: getErrorMessage(error) }) + logger.error(`FileDocStore seed failed for ${name}`, { + error: getErrorMessage(error), + }) throw error } await sleep(backoffWithJitter(attempt + 1, null, { baseMs: 50, maxMs: 500 })) @@ -434,6 +675,64 @@ export class FileDocStore { return false } + /** + * Fences an unsupported durable replacement before deleting its stream. The next authoritative + * seed replaces the tombstone. A separate version watermark deduplicates retries across reseeds; + * both expire with the stream TTL once the document is idle. + */ + async invalidateDocument( + name: string, + version: number + ): Promise<{ status: 'applied'; docId?: string } | { status: 'stale' }> { + if (!this.enabled) { + const now = Date.now() + const previous = this.localInvalidations.get(name) + if (previous && previous.expiresAt > now && previous.version >= version) + return { status: 'stale' } + this.localInvalidations.set(name, { version, expiresAt: now + STREAM_TTL_SEC * 1_000 }) + const room = this.rooms.get(name) + const docId = room?.generationInvalidated + ? undefined + : room?.doc.getMap(FILE_DOC_SEED.configMap).get(FILE_DOC_SEED.docIdKey) + if (room) room.generationInvalidated = true + if (!this.heartbeat) { + this.heartbeat = setInterval(() => void this.refreshTtls(), HEARTBEAT_MS) + this.heartbeat.unref() + } + return { status: 'applied', ...(typeof docId === 'string' ? { docId } : {}) } + } + if (!this.write) throw new Error('FileDocStore is not initialized') + const generation = await this.write.eval(INVALIDATE_DOCUMENT_SCRIPT, { + keys: [ + streamKey(name), + generationKey(name), + `${SYNC_VERSION_PREFIX}${name}`, + `${CLIENT_UPDATE_PREFIX}${name}`, + `${AGENT_STREAM_PREFIX}${name}`, + `${INVALIDATION_VERSION_PREFIX}${name}`, + ], + arguments: [String(version), String(STREAM_TTL_SEC), INVALIDATED_GENERATION], + }) + if (typeof generation !== 'string') return { status: 'stale' } + return { + status: 'applied', + ...(generation && generation !== INVALIDATED_GENERATION ? { docId: generation } : {}), + } + } + + async getDocumentGeneration(name: string): Promise { + if (!this.enabled) return '' + if (!this.write) throw new Error('FileDocStore is not initialized') + return (await this.write.get(generationKey(name))) ?? '' + } + + async isDocumentGenerationCurrent(name: string, generation?: string): Promise { + if (!this.enabled) return !this.rooms.get(name)?.generationInvalidated + if (!this.write) throw new Error('FileDocStore is not initialized') + const current = await this.write.get(generationKey(name)) + return current === null ? !generation : current === generation + } + /** * Whether the file's stream already holds content — an EFFICIENCY recheck in {@link shouldSeed} that * skips the seed fetch when a prior holder already seeded (the split-brain guard itself is the atomic @@ -460,12 +759,15 @@ export class FileDocStore { * disabled store return a truthy token so callers proceed single-replica without special-casing. */ private async acquireLock(key: string, ttlMs: number): Promise { - if (!this.enabled || !this.write) return DISABLED_LOCK_TOKEN + if (!this.enabled) return DISABLED_LOCK_TOKEN + if (!this.write) return null const token = generateId() try { return (await this.write.set(key, token, { NX: true, PX: ttlMs })) === 'OK' ? token : null } catch (error) { - logger.warn(`FileDocStore lock ${key} failed`, { error: getErrorMessage(error) }) + logger.warn(`FileDocStore lock ${key} failed`, { + error: getErrorMessage(error), + }) return null } } @@ -504,19 +806,78 @@ export class FileDocStore { * `null` when the stream is empty — i.e. no doc is (or was recently) live, so there is nothing to * merge into and the caller should fall back to a direct file write. Disabled → always null. */ - async getStreamState(name: string): Promise { - if (!this.enabled || !this.write) return null - const entries = await this.write.xRange(streamKey(name), '-', '+') - if (entries.length === 0) return null + async getStreamState(name: string, expectedGeneration?: string): Promise { + if (!this.enabled) return null + if (!this.write) throw new Error('FileDocStore is not initialized') const doc = new Y.Doc() try { - for (const entry of entries) applyEntryToDoc(doc, entry.id, entry.message) + const generation = await this.getDocumentGeneration(name) + if (expectedGeneration !== undefined && generation !== expectedGeneration) { + throw new FileDocInvalidatedError() + } + const count = await this.replayEntries(name, '0', (entry) => { + if (entry.message[GENERATION_FIELD] && entry.message[GENERATION_FIELD] !== generation) { + throw new FileDocInvalidatedError() + } + applyEntryToDoc(doc, entry.id, entry.message) + return true + }) + if ((await this.getDocumentGeneration(name)) !== generation) { + throw new FileDocInvalidatedError() + } + if (count === 0) return null return Y.encodeStateAsUpdate(doc) } finally { doc.destroy() } } + private async replayEntries( + name: string, + afterId: string, + visit: (entry: { id: string; message: Record }) => boolean + ): Promise { + if (!this.write) return 0 + const key = streamKey(name) + let firstId = (await this.write.xRange(key, '-', '+', { COUNT: 1 }))[0]?.id + if (!firstId) return 0 + const tail = await this.write.xRevRange(key, '+', '-', { COUNT: 1 }) + if (tail.length === 0) throw new FileDocInvalidatedError() + let endId = tail[0].id + let cursor = afterId.includes('-') ? afterId : `${afterId}-0` + let entriesRead = 0 + let encodedBytes = 0 + + while (true) { + while (isAfterStreamId(endId, cursor)) { + const page = await this.write.xRange(key, `(${cursor}`, '+', { + COUNT: REPLAY_PAGE_COUNT, + }) + if (page.length === 0) { + throw new Error(`File document replay lost its completion barrier for ${name}`) + } + for (const entry of page) { + entriesRead += 1 + encodedBytes += entry.message[UPDATE_FIELD]?.length ?? 0 + if (entriesRead > REPLAY_MAX_ENTRIES || encodedBytes > REPLAY_MAX_ENCODED_BYTES) { + throw new Error(`File document replay exceeded its safety limit for ${name}`) + } + cursor = entry.id + if (!visit(entry)) return entriesRead + } + } + + /** Compaction appends its snapshot before trimming; extend the barrier without rereading it. */ + const currentFirstId = (await this.write.xRange(key, '-', '+', { COUNT: 1 }))[0]?.id + if (!currentFirstId) throw new FileDocInvalidatedError() + if (currentFirstId === firstId) return entriesRead + firstId = currentFirstId + const currentTail = await this.write.xRevRange(key, '+', '-', { COUNT: 1 }) + if (currentTail.length === 0) throw new FileDocInvalidatedError() + endId = currentTail[0].id + } + } + /** Release the seed lock (compare-and-delete) once the seed has been published or a seed attempt failed. */ async releaseSeedLock(name: string, token: string): Promise { await this.releaseLock(`${SEED_LOCK_PREFIX}${name}`, token) @@ -580,7 +941,11 @@ export class FileDocStore { * new value exceeds the stored one ({@link SET_VERSION_IF_NEWER_SCRIPT}), so an out-of-order * fire-and-forget write can't regress the token. Best-effort; TTL-bounded like the stream so an idle * file's key can't outlive its room. No-op when disabled (single-pod fallback). */ - async setSyncedVersion(name: string, version: number): Promise { + async setSyncedVersion( + name: string, + version: number, + expectedGeneration = this.rooms.get(name)?.generation ?? '' + ): Promise { if (!this.enabled || !this.write) return // Retry a transient failure (bounded) rather than swallow it: this token is the ONLY way a // peer-seeded task learns the durable version, so a dropped write would leave that peer's persists @@ -589,8 +954,8 @@ export class FileDocStore { for (let attempt = 0; attempt <= PUBLISH_MAX_RETRIES; attempt++) { try { await this.write.eval(SET_VERSION_IF_NEWER_SCRIPT, { - keys: [`${SYNC_VERSION_PREFIX}${name}`], - arguments: [String(version), String(STREAM_TTL_SEC)], + keys: [`${SYNC_VERSION_PREFIX}${name}`, generationKey(name)], + arguments: [String(version), String(STREAM_TTL_SEC), expectedGeneration], }) return } catch (error) { @@ -637,8 +1002,35 @@ export class FileDocStore { await this.releaseLock(`${MERGE_LOCK_PREFIX}${name}`, token) } - private applyEntry(room: StoreRoom, id: string, message: Record): void { + private applyEntry( + name: string, + room: StoreRoom, + id: string, + message: Record + ): void { + if (!isAfterStreamId(id, room.lastId)) return room.lastId = id + const generation = message[GENERATION_FIELD] + if (generation) { + if ( + room.generationInvalidated || + (room.generation !== null && room.generation !== generation) || + (room.generation === null && + room.seededObserved && + room.doc.getMap(FILE_DOC_SEED.configMap).get(FILE_DOC_SEED.docIdKey) !== generation) + ) { + room.generationInvalidated = true + return + } + room.generation = generation + } + if (room.generationInvalidated) return + const isSnapshot = + message[GENERATION_FIELD] !== undefined || + message[SNAPSHOT_FIELD] !== undefined || + message[COMPACTION_FIELD] !== undefined + room.lastDeltaBytes = isSnapshot ? 0 : (message[UPDATE_FIELD]?.length ?? 0) + room.uncompactedDeltaBytes += room.lastDeltaBytes // A compaction snapshot folds seed + edits into one frame; stamp it so the relay's edit-tracker // treats a fresh catch-up from it as edited (a snapshot only exists once real edits accumulated). An // agent-streamed preview frame is stamped separately so the tracker NEVER marks it edited. @@ -657,6 +1049,9 @@ export class FileDocStore { if (origin === REDIS_SNAPSHOT_ORIGIN || (origin === REDIS_ORIGIN && seededBefore)) { room.realEdited = true } + if (room.uncompactedDeltaBytes - room.lastDeltaBytes >= COMPACT_ENCODED_BYTES) { + void this.maybeCompact(name) + } } /** @@ -665,6 +1060,7 @@ export class FileDocStore { */ private async runReader(): Promise { let failures = 0 + let blockingBatchIndex = 0 while (this.running && this.read) { const snapshot = new Map(this.rooms) if (snapshot.size === 0) { @@ -672,26 +1068,40 @@ export class FileDocStore { continue } try { - const res = await this.read.xRead( - [...snapshot].map(([name, room]) => ({ key: streamKey(name), id: room.lastId })), - { BLOCK: READ_BLOCK_MS, COUNT: READ_COUNT } - ) - // The streak ends HERE, on the read returning at all — not further down once entries are - // applied. A blocking read that times out with nothing new is the idle steady state, and it - // proves the connection works just as well as one carrying messages; leaving the streak - // standing through it would keep an old outage's count alive indefinitely, so the next - // unrelated blip would open at the backoff cap and log a failure count it never earned. - failures = 0 - if (!res) continue - for (const stream of res) { - const name = stream.name.slice(STREAM_PREFIX.length) - const room = this.rooms.get(name) - // Skip if detached mid-read, OR replaced by a close→reopen (a DIFFERENT StoreRoom): applying - // entries read against the OLD room's lastId to the new one could regress its lastId (harmless - // but wasteful re-delivery). The new room caught itself up via xRange already. - if (!room || room !== snapshot.get(name)) continue - for (const entry of stream.messages) this.applyEntry(room, entry.id, entry.message) + const rooms = [...snapshot] + const batches: Array = [] + for (let index = 0; index < rooms.length; index += READ_STREAM_BATCH_SIZE) { + batches.push(rooms.slice(index, index + READ_STREAM_BATCH_SIZE)) + } + const applyResults = (results: Awaited>): boolean => { + if (!results) return false + for (const stream of results) { + const name = stream.name.slice(STREAM_PREFIX.length) + const room = this.rooms.get(name) + if (!room || room !== snapshot.get(name)) continue + for (const entry of stream.messages) + this.applyEntry(name, room, entry.id, entry.message) + } + return true + } + let received = false + for (const batch of batches) { + const streams = batch.map(([name, room]) => ({ + key: streamKey(name), + id: room.lastId, + })) + received = applyResults(await this.read.xRead(streams, { COUNT: READ_COUNT })) || received } + if (!received) { + const batch = batches[blockingBatchIndex % batches.length] + blockingBatchIndex = (blockingBatchIndex + 1) % batches.length + const streams = batch.map(([name, room]) => ({ + key: streamKey(name), + id: room.lastId, + })) + applyResults(await this.read.xRead(streams, { BLOCK: READ_BLOCK_MS, COUNT: READ_COUNT })) + } + failures = 0 } catch (error) { if (!this.running) break await this.recoverReader(++failures, error) @@ -718,7 +1128,12 @@ export class FileDocStore { error: getErrorMessage(error), }) } - await sleep(backoffWithJitter(failures, null, { baseMs: 500, maxMs: READER_RETRY_MAX_MS })) + await sleep( + backoffWithJitter(failures, null, { + baseMs: 500, + maxMs: READER_RETRY_MAX_MS, + }) + ) if (this.running && this.read && !this.read.isOpen) { await this.read.connect().catch((reconnectError) => { logger.warn('FileDocStore could not re-open the reader connection', { @@ -737,13 +1152,23 @@ export class FileDocStore { private async maybeCompact(name: string): Promise { if (!this.write) return const room = this.rooms.get(name) - if (!room) return + if (!room || room.compacting || Date.now() < room.compactRetryAfter) return + room.compacting = true try { - if ((await this.write.xLen(streamKey(name))) < COMPACT_THRESHOLD) return + const streamLength = await this.write.xLen(streamKey(name)) + if ( + streamLength < COMPACT_THRESHOLD && + room.uncompactedDeltaBytes - room.lastDeltaBytes < COMPACT_ENCODED_BYTES + ) { + return + } const key = `${COMPACT_LOCK_PREFIX}${name}` const token = await this.acquireLock(key, COMPACT_LOCK_TTL_MS) if (!token) return try { + /** Integrate the completed stream prefix before capturing the snapshot and compaction barrier. */ + await this.catchUp(name) + if (this.rooms.get(name) !== room) return // Capture the snapshot AND the id it covers in one synchronous step (no await between): the // snapshot is `room.doc`, which holds exactly what this task's tailer has integrated — every // entry up to `room.lastId`. Entries a peer task published AFTER that (id > lastId) are NOT in @@ -751,34 +1176,64 @@ export class FileDocStore { // them — only entries the snapshot provably subsumes (id <= lastId). Trimming to the freshly // appended snapshot id instead would silently drop those un-integrated peer entries. const upTo = room.lastId + /** Ordered, deduplicated replay lets two counters represent the prefix without a per-entry map. */ + const deltaBytesAtBarrier = room.uncompactedDeltaBytes - room.lastDeltaBytes const snapshot = Buffer.from(Y.encodeStateAsUpdate(room.doc)).toString('base64') // Stamp the snapshot by what it folds: a real edit → SNAPSHOT_FIELD (a fresh catch-up treats it // as edited content, not a bare seed). An agent-ONLY stream (no real edit yet) → AGENT_FIELD, so a // peer catching up applies it as REDIS_AGENT_ORIGIN and never marks the doc edited — preserving // the no-persist guarantee even when a long copilot stream alone crosses the compaction threshold. const marker = room.realEdited ? SNAPSHOT_FIELD : AGENT_FIELD - await this.write.xAdd(streamKey(name), '*', { - [UPDATE_FIELD]: snapshot, - [marker]: '1', + const snapshotId = await this.write.eval(APPEND_SNAPSHOT_SCRIPT, { + keys: [streamKey(name), generationKey(name)], + arguments: [ + UPDATE_FIELD, + snapshot, + marker, + room.generation ?? '', + GENERATION_FIELD, + upTo, + COMPACTION_FIELD, + ], }) - // MINID keeps entries with id >= upTo: the snapshot, any un-integrated peer entries, and - // `upTo` itself (redundant with the snapshot, harmless); it drops only the folded older deltas. - await this.write.xTrim(streamKey(name), 'MINID', upTo) + if (typeof snapshotId !== 'string') return + /** MINID retains the barrier entry and later deltas, including those observed during the await. */ + room.uncompactedDeltaBytes = Math.max(0, room.uncompactedDeltaBytes - deltaBytesAtBarrier) } finally { await this.releaseLock(key, token) } } catch (error) { - logger.warn(`FileDocStore compaction failed for ${name}`, { error: getErrorMessage(error) }) + room.compactRetryAfter = Date.now() + COMPACT_RETRY_COOLDOWN_MS + logger.warn(`FileDocStore compaction failed for ${name}`, { + error: getErrorMessage(error), + }) + } finally { + room.compacting = false } } + private async refreshDocumentTtls(name: string): Promise { + await this.write?.eval(REFRESH_DOCUMENT_TTLS_SCRIPT, { + keys: documentKeys(name), + arguments: [String(STREAM_TTL_SEC)], + }) + } + private async refreshTtls(): Promise { - if (!this.write) return + if (!this.write) { + const now = Date.now() + for (const [name, invalidation] of this.localInvalidations) { + if (this.rooms.has(name)) invalidation.expiresAt = now + STREAM_TTL_SEC * 1_000 + else if (invalidation.expiresAt <= now) this.localInvalidations.delete(name) + } + if (this.localInvalidations.size === 0 && this.heartbeat) { + clearInterval(this.heartbeat) + this.heartbeat = null + } + return + } for (const name of this.rooms.keys()) { - await this.write.expire(streamKey(name), STREAM_TTL_SEC).catch(() => {}) - // Keep the synced-version key alive as long as its stream, so an open-but-idle doc's persist - // If-Match token can't expire out from under it (which would force a needless reconcile). - await this.write.expire(`${SYNC_VERSION_PREFIX}${name}`, STREAM_TTL_SEC).catch(() => {}) + await this.refreshDocumentTtls(name).catch(() => {}) } } } diff --git a/apps/realtime/src/handlers/file-doc.join-readiness.test.ts b/apps/realtime/src/handlers/file-doc.join-readiness.test.ts index 9b7b6a1c7ec..4938c0680f4 100644 --- a/apps/realtime/src/handlers/file-doc.join-readiness.test.ts +++ b/apps/realtime/src/handlers/file-doc.join-readiness.test.ts @@ -63,12 +63,19 @@ vi.mock('redis', () => { for (let i = 0; i < backing.readDelayTicks; i++) await Promise.resolve() return (backing.streams.get(key) ?? []).map((e) => ({ ...e })) }, + xRevRange: async (key: string) => + [...(backing.streams.get(key) ?? [])] + .reverse() + .slice(0, 1) + .map((entry) => ({ ...entry })), xLen: async (key: string) => (backing.streams.get(key) ?? []).length, - xRead: async (streams: { key: string; id: string }[]) => { + xRead: async (streams: { key: string; id: string }[], options?: { COUNT?: number }) => { const res: { name: string; messages: { id: string; message: Record }[] }[] = [] for (const { key, id } of streams) { - const after = (backing.streams.get(key) ?? []).filter((e) => seqOf(e.id) > seqOf(id)) + const after = (backing.streams.get(key) ?? []) + .filter((e) => seqOf(e.id) > seqOf(id)) + .slice(0, options?.COUNT) if (after.length) res.push({ name: key, messages: after.map((e) => ({ ...e })) }) } if (res.length) return res diff --git a/apps/realtime/src/handlers/file-doc.multireplica.test.ts b/apps/realtime/src/handlers/file-doc.multireplica.test.ts index cfffe81e857..cb24909618f 100644 --- a/apps/realtime/src/handlers/file-doc.multireplica.test.ts +++ b/apps/realtime/src/handlers/file-doc.multireplica.test.ts @@ -25,6 +25,7 @@ const fakeStore = { versions: new Map(), acquireMergeSlot: vi.fn(async () => 'token'), releaseMergeSlot: vi.fn(async () => {}), + getDocumentGeneration: vi.fn(async () => 'shared-generation'), getStreamState: vi.fn(async () => new Uint8Array([1])), publishAndWait: vi.fn(async () => {}), getSyncedVersion: vi.fn(async (name: string) => fakeStore.versions.get(name) ?? null), @@ -67,7 +68,13 @@ describe('applyMarkdownToLiveFileDoc — multi-replica (store-enabled) ordering' expect(await applyMarkdownToLiveFileDoc('file-1', '# durable', { version: 100 })).toBe( 'applied' ) - expect(fakeStore.setSyncedVersion).toHaveBeenCalledWith(ROOM_NAME, 100) + expect(fakeStore.setSyncedVersion).toHaveBeenCalledWith(ROOM_NAME, 100, 'shared-generation') + expect(fakeStore.getStreamState).toHaveBeenCalledWith(ROOM_NAME, 'shared-generation') + expect(fakeStore.publishAndWait).toHaveBeenCalledWith( + ROOM_NAME, + expect.any(Uint8Array), + 'shared-generation' + ) mockFetchFileDocMerge.mockClear() // A durable write with an OLDER version than the SHARED synced version is stale — rejected under the @@ -81,7 +88,7 @@ describe('applyMarkdownToLiveFileDoc — multi-replica (store-enabled) ordering' expect(await applyMarkdownToLiveFileDoc('file-1', '# durable again', { version: 150 })).toBe( 'applied' ) - expect(fakeStore.setSyncedVersion).toHaveBeenCalledWith(ROOM_NAME, 150) + expect(fakeStore.setSyncedVersion).toHaveBeenCalledWith(ROOM_NAME, 150, 'shared-generation') // setSyncedVersion fired only for the two applied durable writes, never for the stale one. expect(fakeStore.setSyncedVersion).toHaveBeenCalledTimes(2) }) @@ -98,7 +105,7 @@ describe('applyMarkdownToLiveFileDoc — multi-replica (store-enabled) ordering' ).toBe('applied') expect(mockFetchFileDocMerge).not.toHaveBeenCalled() // content deferred to the client expect(fakeStore.publishAndWait).not.toHaveBeenCalled() - expect(fakeStore.setSyncedVersion).toHaveBeenCalledWith(ROOM_NAME, 100) // version still recorded + expect(fakeStore.setSyncedVersion).toHaveBeenCalledWith(ROOM_NAME, 100, 'shared-generation') // version still recorded // Once streaming stops the flag clears and the (now near-noop) durable merge resumes normally. fakeStore.isAgentStreaming.mockResolvedValue(false) diff --git a/apps/realtime/src/handlers/file-doc.test.ts b/apps/realtime/src/handlers/file-doc.test.ts index c0878d90129..3bc5b90c5c4 100644 --- a/apps/realtime/src/handlers/file-doc.test.ts +++ b/apps/realtime/src/handlers/file-doc.test.ts @@ -3,6 +3,7 @@ */ import { FILE_DOC_EVENTS, + FILE_DOC_LIMITS, FILE_DOC_MESSAGE_TYPE, FILE_DOC_SEED, } from '@sim/realtime-protocol/file-doc' @@ -37,12 +38,20 @@ vi.mock('@/handlers/file-doc-app', () => ({ import { applyMarkdownToLiveFileDoc, cleanupFileDocForSocket, + fileDocAdmissionRoom, flushAllFileDocRooms, + invalidateLiveFileDocument, setupWorkspaceFileDocHandlers, } from '@/handlers/file-doc' -import { beginRoomPermissionRead, commitRoomPermission } from '@/middleware/permissions' +import { FileDocInvalidatedError, getFileDocStore } from '@/handlers/file-doc-store' +import * as permissions from '@/middleware/permissions' +import { + beginRoomPermissionRead, + commitRoomPermission, + ROLE_REVALIDATION_TTL_MS, +} from '@/middleware/permissions' -type Handler = (payload?: unknown) => Promise | void +type Handler = (...payload: unknown[]) => Promise | void const ROOM_NAME = 'workspace-file-doc:file-1' @@ -54,16 +63,19 @@ interface SentMessage { } /** An `io` mock that records every server-originated emit with its target/except. */ -function createIo() { +function createIo(deliver?: (message: SentMessage) => void) { const sent: SentMessage[] = [] + const emit = (message: SentMessage) => { + sent.push(message) + deliver?.(message) + } /** Records `io.in(socketId).socketsLeave(room)` — a socket forced out of a room from outside. */ const left: { socketId: string; room: string }[] = [] const to = vi.fn((target: string) => ({ except: (exclude: string) => ({ - emit: (event: string, payload: unknown) => - sent.push({ target, except: exclude, event, payload }), + emit: (event: string, payload: unknown) => emit({ target, except: exclude, event, payload }), }), - emit: (event: string, payload: unknown) => sent.push({ target, event, payload }), + emit: (event: string, payload: unknown) => emit({ target, event, payload }), })) const inFn = vi.fn((socketId: string) => ({ socketsLeave: (room: string) => { @@ -133,10 +145,14 @@ async function flushMicrotasks(): Promise { * An encoded Yjs update shaped like the server seed builder's output: some content in the shared * `default` type plus the {@link FILE_DOC_SEED} flag, so applying it marks the doc seeded. */ -function seedResult(content: string): { update: Uint8Array; version: number } { +function seedResult( + content: string, + docId = 'doc-default' +): { update: Uint8Array; version: number } { const doc = new Y.Doc() doc.getText(FILE_DOC_FIELD).insert(0, content) doc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.flag, true) + if (docId) doc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.docIdKey, docId) return { update: Y.encodeStateAsUpdate(doc), version: 1 } } @@ -186,9 +202,7 @@ describe('setupWorkspaceFileDocHandlers', () => { workspaceId: 'ws-1', workspacePermission: 'write', }) - // Default: the server seed builder returns no content (empty file). Tests that - // exercise seeding override this per-case with an encoded Yjs update. - mockFetchFileDocSeed.mockResolvedValue(null) + mockFetchFileDocSeed.mockResolvedValue(seedResult('')) // Default: the merge builder returns a valid no-op (empty-doc) update. Tests exercising copilot // merges override it. mockFetchFileDocMerge.mockResolvedValue(Y.encodeStateAsUpdate(new Y.Doc())) @@ -196,13 +210,14 @@ describe('setupWorkspaceFileDocHandlers', () => { mockFetchFileDocPersist.mockResolvedValue({ status: 'persisted', version: 1 }) }) - afterEach(() => { + afterEach(async () => { // The room store is module-global; drop every room the test's sockets opened. const { io } = createIo() // Simulate a full disconnect between tests (`endOfLife`) so the module-global join-generation // map is cleared and never bleeds a counter into the next test. for (const id of createdSocketIds) cleanupFileDocForSocket(id, io, true) createdSocketIds.clear() + await getFileDocStore().shutdown() }) it('rejects join when the socket is not authenticated', async () => { @@ -217,6 +232,24 @@ describe('setupWorkspaceFileDocHandlers', () => { ) }) + it('fails closed when authorization does not resolve a workspace context', async () => { + mockAuthorizeRoom.mockResolvedValueOnce({ + allowed: true, + status: 200, + workspacePermission: 'write', + }) + const { io } = createIo() + const { socket, handlers } = setup('socket-no-workspace', io) + + await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + + expect(socket.emit).toHaveBeenCalledWith( + FILE_DOC_EVENTS.JOIN_ERROR, + expect.objectContaining({ code: 'JOIN_FAILED', retryable: true }) + ) + expect(socket.join).not.toHaveBeenCalled() + }) + it('rejects join with a retryable error when realtime is unavailable', async () => { const { io } = createIo() const { socket, handlers } = createSocket('socket-1') @@ -248,6 +281,264 @@ describe('setupWorkspaceFileDocHandlers', () => { expect(mockAuthorizeRoom).not.toHaveBeenCalled() }) + it('rejects an incompatible collaborative-document schema before authorizing', async () => { + const { io } = createIo() + const { socket, handlers } = setup('socket-schema', io) + + await handlers[FILE_DOC_EVENTS.JOIN]({ + fileId: 'file-1', + clientId: 1, + schemaVersion: 99, + }) + + expect(socket.emit).toHaveBeenCalledWith( + FILE_DOC_EVENTS.JOIN_ERROR, + expect.objectContaining({ code: 'SCHEMA_VERSION_MISMATCH', retryable: false }) + ) + expect(mockAuthorizeRoom).not.toHaveBeenCalled() + }) + + it('acknowledges user updates only after applying them to the joined document', async () => { + mockFetchFileDocSeed.mockResolvedValue(seedResult('# Original', 'doc-1')) + const { io, sent } = createIo() + const { handlers } = setup('socket-update', io) + await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + + const source = new Y.Doc() + source.getText(FILE_DOC_FIELD).insert(0, 'acknowledged edit') + const acknowledge = vi.fn() + sent.length = 0 + + await handlers[FILE_DOC_EVENTS.UPDATE]( + { + fileId: 'file-1', + docId: 'doc-1', + updateId: 'update-1', + update: Y.encodeStateAsUpdate(source), + }, + acknowledge + ) + + await vi.waitFor(() => + expect(acknowledge).toHaveBeenCalledWith({ status: 'accepted', updateId: 'update-1' }) + ) + expect(sent).toContainEqual( + expect.objectContaining({ + target: ROOM_NAME, + event: FILE_DOC_EVENTS.MESSAGE, + }) + ) + source.destroy() + }) + + it('rejects an update for a replaced document without applying it', async () => { + mockFetchFileDocSeed.mockResolvedValue(seedResult('# Original', 'doc-current')) + const { io, sent } = createIo() + const { handlers } = setup('socket-replaced', io) + await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + const acknowledge = vi.fn() + sent.length = 0 + + await handlers[FILE_DOC_EVENTS.UPDATE]( + { + fileId: 'file-1', + docId: 'doc-stale', + updateId: 'update-stale', + update: Y.encodeStateAsUpdate(new Y.Doc()), + }, + acknowledge + ) + + expect(acknowledge).toHaveBeenCalledWith({ + status: 'rejected', + code: 'DOCUMENT_REPLACED', + retryable: false, + updateId: 'update-stale', + }) + expect(sent).toHaveLength(0) + }) + + it('rejects malformed Yjs updates without retrying them', async () => { + mockFetchFileDocSeed.mockResolvedValue(seedResult('# Original', 'doc-1')) + const { io } = createIo() + const { handlers } = setup('socket-malformed-update', io) + await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + const acknowledge = vi.fn() + + await handlers[FILE_DOC_EVENTS.UPDATE]( + { + fileId: 'file-1', + docId: 'doc-1', + updateId: 'update-malformed', + update: new Uint8Array([255]), + }, + acknowledge + ) + + expect(acknowledge).toHaveBeenCalledWith({ + status: 'rejected', + code: 'INVALID_UPDATE', + retryable: false, + updateId: 'update-malformed', + }) + }) + + it('ignores an acknowledged-update event without a callable acknowledgement', async () => { + mockFetchFileDocSeed.mockResolvedValue(seedResult('# Original', 'doc-1')) + const { io } = createIo() + const { handlers } = setup('socket-missing-ack', io) + await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + + expect(() => + handlers[FILE_DOC_EVENTS.UPDATE]( + { + fileId: 'file-1', + docId: 'doc-1', + updateId: 'update-1', + update: Y.encodeStateAsUpdate(new Y.Doc()), + }, + { not: 'a function' } + ) + ).not.toThrow() + }) + + it('keeps a room alive until an acknowledged update finishes appending', async () => { + mockFetchFileDocSeed.mockResolvedValue(seedResult('# Original', 'doc-1')) + let resolveAppend: () => void = () => {} + const append = new Promise((resolve) => { + resolveAppend = resolve + }) + const publish = vi + .spyOn(getFileDocStore(), 'publishClientUpdateAndWait') + .mockReturnValue(append) + const { io } = createIo() + const { handlers } = setup('socket-update-leave', io) + await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + const source = new Y.Doc() + source.getText(FILE_DOC_FIELD).insert(0, 'accepted before leave') + const acknowledge = vi.fn() + + handlers[FILE_DOC_EVENTS.UPDATE]( + { + fileId: 'file-1', + docId: 'doc-1', + updateId: 'update-leave', + update: Y.encodeStateAsUpdate(source), + }, + acknowledge + ) + await vi.waitFor(() => expect(publish).toHaveBeenCalledTimes(1)) + handlers[FILE_DOC_EVENTS.LEAVE]({ fileId: 'file-1' }) + resolveAppend() + + await vi.waitFor(() => + expect(acknowledge).toHaveBeenCalledWith({ + status: 'accepted', + updateId: 'update-leave', + }) + ) + publish.mockRestore() + source.destroy() + }) + + it('rejects a generation-fenced update as a durable document replacement', async () => { + mockFetchFileDocSeed.mockResolvedValue(seedResult('# Original', 'doc-1')) + const publish = vi + .spyOn(getFileDocStore(), 'publishClientUpdateAndWait') + .mockRejectedValue(new FileDocInvalidatedError()) + const { io } = createIo() + const { handlers } = setup('socket-replaced-update', io) + await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + const source = new Y.Doc() + source.getText(FILE_DOC_FIELD).insert(0, 'stale edit') + const acknowledge = vi.fn() + + await handlers[FILE_DOC_EVENTS.UPDATE]( + { + fileId: 'file-1', + docId: 'doc-1', + updateId: 'update-replaced', + update: Y.encodeStateAsUpdate(source), + }, + acknowledge + ) + + expect(acknowledge).toHaveBeenCalledWith({ + status: 'rejected', + code: 'DOCUMENT_REPLACED', + retryable: false, + updateId: 'update-replaced', + }) + publish.mockRestore() + source.destroy() + }) + + it.each(['before append', 'during append', 'during append and reseed'] as const)( + 'rejects a document invalidated %s without applying or relaying its stale update', + async (timing) => { + mockFetchFileDocSeed.mockResolvedValue(seedResult('Original', 'doc-race')) + const { io, sent } = createIo() + const { handlers } = setup('socket-generation-race', io) + await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + const store = getFileDocStore() + let finishAppend!: () => void + const pendingAppend = new Promise((resolve) => { + finishAppend = resolve + }) + const publish = + timing !== 'before append' + ? vi.spyOn(store, 'publishClientUpdateAndWait').mockReturnValueOnce(pendingAppend) + : undefined + const source = new Y.Doc() + source.getText(FILE_DOC_FIELD).insert(0, 'Stale text') + const acknowledge = vi.fn() + try { + if (timing === 'before append') await invalidateLiveFileDocument('file-1', 2) + sent.length = 0 + handlers[FILE_DOC_EVENTS.UPDATE]( + { + fileId: 'file-1', + docId: 'doc-race', + updateId: 'update-race', + update: Y.encodeStateAsUpdate(source), + }, + acknowledge + ) + if (timing !== 'before append') { + expect(publish).toHaveBeenCalledTimes(1) + await invalidateLiveFileDocument('file-1', 2) + if (timing === 'during append and reseed') { + mockFetchFileDocSeed.mockResolvedValue({ + ...seedResult('Replacement', 'doc-new'), + version: 2, + }) + const fresh = setup('socket-generation-fresh', io) + await fresh.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 2 }) + expect(fresh.socket.emit).toHaveBeenCalledWith( + FILE_DOC_EVENTS.JOIN_SUCCESS, + expect.objectContaining({ docId: 'doc-new' }) + ) + sent.length = 0 + } + finishAppend() + } + await vi.waitFor(() => + expect(acknowledge).toHaveBeenCalledWith({ + status: 'rejected', + code: 'DOCUMENT_REPLACED', + retryable: false, + updateId: 'update-race', + }) + ) + expect(sent).toHaveLength(0) + } finally { + finishAppend() + publish?.mockRestore() + source.destroy() + } + } + ) + it('does not re-enter the room when access was revoked while the join was in flight', async () => { // The sweep records a revocation before it evicts, so a join whose authorize // completed just before that must not put the socket back in the document. @@ -369,6 +660,125 @@ describe('setupWorkspaceFileDocHandlers', () => { expect(mockFetchFileDocPersist).toHaveBeenCalled() }) + it('awaits final persistence of an already removed room during shutdown', async () => { + mockFetchFileDocSeed.mockResolvedValue(seedResult('# From server')) + const { io } = createIo() + const { handlers } = setup('socket-final-persist', io) + await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + const edit = new Y.Doc() + edit.getText(FILE_DOC_FIELD).insert(0, 'last edit') + handlers[FILE_DOC_EVENTS.MESSAGE]( + frame(FILE_DOC_MESSAGE_TYPE.SYNC, (encoder) => + syncProtocol.writeUpdate(encoder, Y.encodeStateAsUpdate(edit)) + ) + ) + let finishPersist!: (result: { status: 'persisted'; version: number }) => void + mockFetchFileDocPersist.mockReturnValueOnce( + new Promise((resolve) => { + finishPersist = resolve + }) + ) + cleanupFileDocForSocket('socket-final-persist', io, true) + await flushMicrotasks() + expect(mockFetchFileDocPersist).toHaveBeenCalledTimes(1) + const completed = vi.fn() + const flush = flushAllFileDocRooms().then(completed) + await flushMicrotasks() + expect(completed).not.toHaveBeenCalled() + finishPersist({ status: 'persisted', version: 2 }) + await flush + expect(completed).toHaveBeenCalledTimes(1) + edit.destroy() + }) + + it('drains an accepted update still appending when the last socket closes', async () => { + mockFetchFileDocSeed.mockResolvedValue(seedResult('# From server', 'shutdown-doc')) + const { io } = createIo() + const { handlers } = setup('socket-closing-update', io) + await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + let finishAppend!: () => void + const append = vi.spyOn(getFileDocStore(), 'publishClientUpdateAndWait').mockReturnValueOnce( + new Promise((resolve) => { + finishAppend = resolve + }) + ) + const edit = new Y.Doc() + edit.getText(FILE_DOC_FIELD).insert(0, 'accepted before socket close') + const ack = vi.fn() + handlers[FILE_DOC_EVENTS.UPDATE]( + { + fileId: 'file-1', + docId: 'shutdown-doc', + updateId: 'shutdown-update', + update: Y.encodeStateAsUpdate(edit), + }, + ack + ) + cleanupFileDocForSocket('socket-closing-update', io, true) + const completed = vi.fn() + const flush = flushAllFileDocRooms().then(completed) + await flushMicrotasks() + expect(completed).not.toHaveBeenCalled() + expect(mockFetchFileDocPersist).not.toHaveBeenCalled() + finishAppend() + await flush + expect(ack).toHaveBeenCalledWith({ status: 'accepted', updateId: 'shutdown-update' }) + expect(mockFetchFileDocPersist).toHaveBeenCalledTimes(1) + const persisted = new Y.Doc() + Y.applyUpdate(persisted, mockFetchFileDocPersist.mock.calls[0][3]) + expect(persisted.getText(FILE_DOC_FIELD).toString()).toContain('accepted before socket close') + append.mockRestore() + edit.destroy() + persisted.destroy() + }) + + it.each(['persist', 'invalidate', 'leave'] as const)( + 'retries a throttled persist safely until %s', + async (outcome) => { + vi.useFakeTimers() + const store = getFileDocStore() + const claim = vi + .spyOn(store, 'tryClaimPersistWindow') + .mockResolvedValueOnce(false) + .mockResolvedValue(true) + try { + mockFetchFileDocSeed.mockResolvedValue(seedResult('# From server')) + const { io } = createIo() + const { handlers } = setup('socket-throttled', io) + await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + const edit = new Y.Doc() + edit.getText(FILE_DOC_FIELD).insert(0, 'pending durable edit') + handlers[FILE_DOC_EVENTS.MESSAGE]( + frame(FILE_DOC_MESSAGE_TYPE.SYNC, (encoder) => + syncProtocol.writeUpdate(encoder, Y.encodeStateAsUpdate(edit)) + ) + ) + await vi.advanceTimersByTimeAsync(5_000) + expect(claim).toHaveBeenCalledTimes(1) + expect(mockFetchFileDocPersist).not.toHaveBeenCalled() + if (outcome === 'invalidate') await store.invalidateDocument(ROOM_NAME, 2) + if (outcome === 'leave') { + cleanupFileDocForSocket('socket-throttled', io, true) + await vi.advanceTimersByTimeAsync(0) + expect(mockFetchFileDocPersist).toHaveBeenCalledTimes(1) + mockFetchFileDocPersist.mockClear() + } + await vi.advanceTimersByTimeAsync(5_000) + expect(mockFetchFileDocPersist).toHaveBeenCalledTimes(outcome === 'persist' ? 1 : 0) + if (outcome === 'persist') { + const persisted = new Y.Doc() + Y.applyUpdate(persisted, mockFetchFileDocPersist.mock.calls[0][3]) + expect(persisted.getText(FILE_DOC_FIELD).toString()).toContain('pending durable edit') + persisted.destroy() + } + edit.destroy() + } finally { + claim.mockRestore() + vi.useRealTimers() + } + } + ) + it('drops document frames and evicts once the editor loses write access mid-session', async () => { // The join-time check is not a standing right: a collaborator downgraded to `read` // (or removed) must stop landing durable edits on the socket they already hold. @@ -550,6 +960,10 @@ describe('setupWorkspaceFileDocHandlers', () => { FILE_DOC_EVENTS.JOIN_SUCCESS, expect.objectContaining({ fileId: 'file-1', clientId: 1 }) ) + const joinSuccess = socket.emit.mock.calls.find( + ([event]) => event === FILE_DOC_EVENTS.JOIN_SUCCESS + )?.[1] as Record + expect(joinSuccess).not.toHaveProperty('acknowledgedUpdates') // A binary sync-step-1 message (type tag 0) is sent to kick off the handshake. const syncMessage = socket.emit.mock.calls.find( @@ -575,6 +989,479 @@ describe('setupWorkspaceFileDocHandlers', () => { expect(clientDoc.getText(FILE_DOC_FIELD).toString()).toBe('# From server') }) + it('discards a fenced in-memory generation before serving the next join', async () => { + mockFetchFileDocSeed.mockResolvedValue(seedResult('# Old', 'doc-old')) + const { io, left } = createIo() + const first = setup('socket-old-generation', io) + await first.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + + await getFileDocStore().invalidateDocument(ROOM_NAME, 1) + mockFetchFileDocSeed.mockResolvedValue(seedResult('# New', 'doc-new')) + const second = setup('socket-new-generation', io) + await second.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 2 }) + + expect(left).toContainEqual({ socketId: 'socket-old-generation', room: ROOM_NAME }) + second.socket.emit.mockClear() + second.handlers[FILE_DOC_EVENTS.MESSAGE]( + frame(FILE_DOC_MESSAGE_TYPE.SYNC, (encoder) => + syncProtocol.writeSyncStep1(encoder, new Y.Doc()) + ) + ) + const reply = second.socket.emit.mock.calls.find( + ([event, payload]) => event === FILE_DOC_EVENTS.MESSAGE && payload instanceof Uint8Array + ) + const clientDoc = new Y.Doc() + applySyncReply(reply?.[1] as Uint8Array, clientDoc) + expect(clientDoc.getText(FILE_DOC_FIELD).toString()).toBe('# New') + clientDoc.destroy() + }) + + it.each([false, true])( + 'rejects a pending join invalidated during permission with existing room %s', + async (existingRoom) => { + const seed = seedResult('# Old', 'doc-old') + mockFetchFileDocSeed.mockResolvedValue(seed) + const { io, sent } = createIo() + if (existingRoom) { + const first = setup('socket-first-invalidation', io) + await first.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + } + let resolvePermission!: (permission: string) => void + const permission = new Promise((resolve) => { + resolvePermission = resolve + }) + const permissionCheck = vi + .spyOn(permissions, 'resolveCurrentRoomPermission') + .mockImplementationOnce(() => permission) + try { + const pending = setup('socket-pending-invalidation', io) + const joining = pending.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 2 }) + await vi.waitFor(() => expect(permissionCheck).toHaveBeenCalledOnce()) + expect(await invalidateLiveFileDocument('file-1', 2)).toMatchObject({ status: 'applied' }) + io.to(ROOM_NAME).emit(FILE_DOC_EVENTS.INVALIDATED, { fileId: 'file-1' }) + resolvePermission('write') + await joining + + expect(joinSuccessFileId(pending.socket)).toBeUndefined() + expect(pending.socket.emit).toHaveBeenCalledWith( + FILE_DOC_EVENTS.JOIN_ERROR, + expect.objectContaining({ retryable: true }) + ) + const staleClient = new Y.Doc() + Y.applyUpdate(staleClient, seed.update) + const vector = Y.encodeStateVector(staleClient) + staleClient.getText(FILE_DOC_FIELD).insert(0, 'must not accept ') + sent.length = 0 + pending.socket.emit.mockClear() + pending.handlers[FILE_DOC_EVENTS.MESSAGE]( + frame(FILE_DOC_MESSAGE_TYPE.SYNC, (encoder) => + syncProtocol.writeUpdate(encoder, Y.encodeStateAsUpdate(staleClient, vector)) + ) + ) + expect(sent).toHaveLength(0) + expect(pending.socket.emit).not.toHaveBeenCalledWith( + FILE_DOC_EVENTS.MESSAGE, + expect.any(Uint8Array) + ) + await flushAllFileDocRooms() + expect(mockFetchFileDocPersist).not.toHaveBeenCalled() + staleClient.destroy() + + mockFetchFileDocSeed.mockResolvedValue({ ...seedResult('# New', 'doc-new'), version: 2 }) + await pending.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 2 }) + expect(pending.socket.emit).toHaveBeenCalledWith( + FILE_DOC_EVENTS.JOIN_SUCCESS, + expect.objectContaining({ docId: 'doc-new' }) + ) + } finally { + resolvePermission('write') + permissionCheck.mockRestore() + } + } + ) + + it.each(['write', 'read', null] as const)( + 'withholds document and presence broadcasts until final authorization resolves to %s', + async (permission) => { + mockFetchFileDocSeed.mockResolvedValue(seedResult('# Private', 'doc-private')) + const memberships = new Set() + let pending: ReturnType + const { io } = createIo(({ target, event, payload }) => { + if (memberships.has(target)) pending.socket.emit(event, payload) + }) + pending = setup('socket-pending-authorization', io, { + join: vi.fn((name: string) => { + memberships.add(name) + }), + leave: vi.fn((name: string) => { + memberships.delete(name) + }), + }) + let resolvePermission!: (value: 'write' | 'read' | null) => void + const authorization = new Promise<'write' | 'read' | null>((resolve) => { + resolvePermission = resolve + }) + const guard = vi + .spyOn(permissions, 'resolveCurrentRoomPermission') + .mockResolvedValueOnce('write') + .mockImplementationOnce(() => authorization) + const joining = pending.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + try { + await vi.waitFor(() => expect(guard).toHaveBeenCalledTimes(2)) + const content = frame(FILE_DOC_MESSAGE_TYPE.SYNC, (encoder) => + syncProtocol.writeUpdate(encoder, seedResult('# Private update').update) + ) + io.local.to(ROOM_NAME).emit(FILE_DOC_EVENTS.MESSAGE, content) + io.to(ROOM_NAME).emit( + FILE_DOC_EVENTS.MESSAGE, + new Uint8Array([FILE_DOC_MESSAGE_TYPE.AWARENESS]) + ) + io.to(ROOM_NAME).emit(FILE_DOC_EVENTS.PRESENCE, [{ userId: 'private-peer' }]) + expect(pending.socket.emit).not.toHaveBeenCalledWith( + FILE_DOC_EVENTS.MESSAGE, + expect.anything() + ) + expect(pending.socket.emit).not.toHaveBeenCalledWith( + FILE_DOC_EVENTS.PRESENCE, + expect.anything() + ) + expect(memberships.has(ROOM_NAME)).toBe(false) + resolvePermission(permission) + await joining + expect(memberships.has(ROOM_NAME)).toBe(permission === 'write') + if (permission === 'write') { + expect(joinSuccessFileId(pending.socket)).toBe('file-1') + expect(pending.socket.emit).toHaveBeenCalledWith( + FILE_DOC_EVENTS.MESSAGE, + expect.any(Uint8Array) + ) + } else { + expect(joinSuccessFileId(pending.socket)).toBeUndefined() + expect(pending.socket.emit).toHaveBeenCalledWith( + FILE_DOC_EVENTS.JOIN_ERROR, + expect.objectContaining({ code: 'ACCESS_DENIED', retryable: false }) + ) + } + } finally { + resolvePermission(permission) + await joining + guard.mockRestore() + } + } + ) + + it('rejects a generation invalidated while final authorization is pending', async () => { + mockFetchFileDocSeed.mockResolvedValue(seedResult('# Old', 'doc-old')) + const { io } = createIo() + const pending = setup('socket-final-authorization-invalidation', io) + let finishAuthorization!: (permission: 'write') => void + const authorization = new Promise<'write'>((resolve) => { + finishAuthorization = resolve + }) + const guard = vi + .spyOn(permissions, 'resolveCurrentRoomPermission') + .mockResolvedValueOnce('write') + .mockImplementationOnce(() => authorization) + const joining = pending.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + try { + await vi.waitFor(() => expect(guard).toHaveBeenCalledTimes(2)) + await getFileDocStore().invalidateDocument(ROOM_NAME, 2) + finishAuthorization('write') + await joining + expect(pending.socket.join).not.toHaveBeenCalledWith(ROOM_NAME) + expect(joinSuccessFileId(pending.socket)).toBeUndefined() + expect(pending.socket.emit).toHaveBeenCalledWith( + FILE_DOC_EVENTS.JOIN_ERROR, + expect.objectContaining({ code: 'JOIN_FAILED', retryable: true }) + ) + expect(pending.socket.leave).toHaveBeenCalledWith(fileDocAdmissionRoom('file-1')) + } finally { + finishAuthorization('write') + await joining + guard.mockRestore() + } + }) + + it.each(['revoked', 'expired'] as const)( + 'rejects access %s while the final generation check is pending', + async (access) => { + mockFetchFileDocSeed.mockResolvedValue(seedResult('# Private', 'doc-private')) + const { io } = createIo() + const pending = setup('socket-generation-authorization-revocation', io) + let finishGeneration!: (current: boolean) => void + const generation = new Promise((resolve) => { + finishGeneration = resolve + }) + const guard = vi + .spyOn(getFileDocStore(), 'isDocumentGenerationCurrent') + .mockImplementationOnce(() => generation) + const joining = pending.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + const clock = vi.spyOn(Date, 'now') + try { + await vi.waitFor(() => expect(guard).toHaveBeenCalledOnce()) + if (access === 'revoked') { + commitRoomPermission( + 'user-1', + { type: ROOM_TYPES.WORKSPACE_FILE_DOC, id: 'file-1' }, + 'read', + beginRoomPermissionRead() + ) + } else { + clock.mockReturnValue(Date.now() + ROLE_REVALIDATION_TTL_MS + 1) + } + finishGeneration(true) + await joining + expect(pending.socket.join).not.toHaveBeenCalledWith(ROOM_NAME) + expect(joinSuccessFileId(pending.socket)).toBeUndefined() + expect(pending.socket.emit).toHaveBeenCalledWith( + FILE_DOC_EVENTS.JOIN_ERROR, + expect.objectContaining({ + code: access === 'revoked' ? 'ACCESS_DENIED' : 'JOIN_FAILED', + retryable: access === 'expired', + }) + ) + } finally { + clock.mockRestore() + finishGeneration(true) + await joining + guard.mockRestore() + } + } + ) + + it('receives invalidation while the subscribed generation check is pending', async () => { + mockFetchFileDocSeed.mockResolvedValue(seedResult('# Old', 'doc-old')) + const memberships = new Set() + let pending: ReturnType + const { io } = createIo(({ target, event, payload }) => { + if (memberships.has(target)) pending.socket.emit(event, payload) + }) + pending = setup('socket-subscribed-invalidation', io, { + join: vi.fn((name: string) => { + memberships.add(name) + }), + leave: vi.fn((name: string) => { + memberships.delete(name) + }), + }) + let resolveGeneration!: (current: boolean) => void + const currentGeneration = new Promise((resolve) => { + resolveGeneration = resolve + }) + const guard = vi + .spyOn(getFileDocStore(), 'isDocumentGenerationCurrent') + .mockImplementationOnce(() => currentGeneration) + try { + const joining = pending.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + await vi.waitFor(() => expect(guard).toHaveBeenCalledOnce()) + expect(memberships.has(ROOM_NAME)).toBe(false) + expect(memberships.has(fileDocAdmissionRoom('file-1'))).toBe(true) + await getFileDocStore().invalidateDocument(ROOM_NAME, 2) + io.to(fileDocAdmissionRoom('file-1')).emit(FILE_DOC_EVENTS.INVALIDATED, { fileId: 'file-1' }) + expect(pending.socket.emit).toHaveBeenCalledWith(FILE_DOC_EVENTS.INVALIDATED, { + fileId: 'file-1', + }) + await pending.handlers[FILE_DOC_EVENTS.LEAVE]({ fileId: 'file-1' }) + resolveGeneration(true) + await joining + + expect(joinSuccessFileId(pending.socket)).toBeUndefined() + expect(memberships.has(ROOM_NAME)).toBe(false) + expect(memberships.has(fileDocAdmissionRoom('file-1'))).toBe(false) + } finally { + resolveGeneration(true) + guard.mockRestore() + } + }) + + it.each(['invalidation', 'revocation', 'leave'] as const)( + 'rolls back an asynchronous room subscription interrupted by %s', + async (interruption) => { + mockFetchFileDocSeed.mockResolvedValue(seedResult('# Old', 'doc-old')) + const { io } = createIo() + let finishSubscription!: () => void + const subscription = new Promise((resolve) => { + finishSubscription = resolve + }) + const pending = setup('socket-async-subscription', io, { + join: vi.fn(() => subscription), + }) + const joining = pending.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + await vi.waitFor(() => expect(pending.socket.join).toHaveBeenCalledOnce()) + expect(joinSuccessFileId(pending.socket)).toBeUndefined() + if (interruption === 'invalidation') { + await getFileDocStore().invalidateDocument(ROOM_NAME, 2) + } else if (interruption === 'revocation') { + commitRoomPermission( + 'user-1', + { type: ROOM_TYPES.WORKSPACE_FILE_DOC, id: 'file-1' }, + 'read', + beginRoomPermissionRead() + ) + } else { + await pending.handlers[FILE_DOC_EVENTS.LEAVE]({ fileId: 'file-1' }) + } + finishSubscription() + await joining + expect(joinSuccessFileId(pending.socket)).toBeUndefined() + expect(pending.socket.leave).toHaveBeenCalledWith(ROOM_NAME) + } + ) + + it.each(['revoked', 'expired', 'unchanged'] as const)( + 'checks %s access after an asynchronous content-room join', + async (access) => { + mockFetchFileDocSeed.mockResolvedValue(seedResult('# Private', 'doc-private')) + const { io } = createIo() + const memberships = new Set() + let finishSubscription!: () => void + const subscription = new Promise((resolve) => { + finishSubscription = resolve + }) + const pending = setup('socket-content-subscription-access', io, { + join: vi.fn((name: string) => { + if (name === ROOM_NAME) + return subscription.then(() => { + memberships.add(name) + }) + memberships.add(name) + }), + leave: vi.fn((name: string) => memberships.delete(name)), + }) + const clock = vi.spyOn(Date, 'now') + const joining = pending.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + try { + await vi.waitFor(() => expect(pending.socket.join).toHaveBeenCalledWith(ROOM_NAME)) + expect(joinSuccessFileId(pending.socket)).toBeUndefined() + if (access === 'revoked') { + commitRoomPermission( + 'user-1', + { type: ROOM_TYPES.WORKSPACE_FILE_DOC, id: 'file-1' }, + 'read', + beginRoomPermissionRead() + ) + } else if (access === 'expired') { + clock.mockReturnValue(Date.now() + ROLE_REVALIDATION_TTL_MS + 1) + } + finishSubscription() + await joining + expect(memberships.has(fileDocAdmissionRoom('file-1'))).toBe(false) + expect(memberships.has(ROOM_NAME)).toBe(access === 'unchanged') + if (access === 'unchanged') { + expect(joinSuccessFileId(pending.socket)).toBe('file-1') + } else { + expect(joinSuccessFileId(pending.socket)).toBeUndefined() + expect(pending.socket.emit).toHaveBeenCalledWith( + FILE_DOC_EVENTS.JOIN_ERROR, + expect.objectContaining({ + code: access === 'revoked' ? 'ACCESS_DENIED' : 'JOIN_FAILED', + retryable: access === 'expired', + }) + ) + expect(pending.socket.emit).not.toHaveBeenCalledWith( + FILE_DOC_EVENTS.MESSAGE, + expect.anything() + ) + expect(pending.socket.emit).not.toHaveBeenCalledWith( + FILE_DOC_EVENTS.PRESENCE, + expect.anything() + ) + } + } finally { + clock.mockRestore() + finishSubscription() + await joining + } + } + ) + + it('keeps a shared provisional subscription until the other provider finishes joining', async () => { + mockFetchFileDocSeed.mockResolvedValue(seedResult('# Shared', 'doc-shared')) + const { io } = createIo() + const pending = setup('socket-shared-subscription', io) + const checks: Array<(current: boolean) => void> = [] + const guard = vi + .spyOn(getFileDocStore(), 'isDocumentGenerationCurrent') + .mockImplementation(() => new Promise((resolve) => checks.push(resolve))) + try { + const first = pending.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + const second = pending.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 2 }) + await vi.waitFor(() => expect(checks).toHaveLength(2)) + checks[0](false) + await first + expect(pending.socket.leave).not.toHaveBeenCalled() + checks[1](true) + await second + expect(pending.socket.emit).toHaveBeenCalledWith( + FILE_DOC_EVENTS.JOIN_SUCCESS, + expect.objectContaining({ clientId: 2, docId: 'doc-shared' }) + ) + expect(pending.socket.leave).not.toHaveBeenCalledWith(ROOM_NAME) + expect(pending.socket.leave).toHaveBeenCalledWith(fileDocAdmissionRoom('file-1')) + } finally { + for (const resolve of checks) resolve(true) + guard.mockRestore() + } + }) + + it('preserves the committed binding when a co-mounted provider join fails', async () => { + mockFetchFileDocSeed.mockResolvedValue(seedResult('# Shared', 'doc-shared')) + const { io } = createIo() + const current = setup('socket-existing-subscription', io) + await current.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + const guard = vi + .spyOn(getFileDocStore(), 'isDocumentGenerationCurrent') + .mockRejectedValueOnce(new Error('Temporary generation read failure')) + try { + await current.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 2 }) + expect(current.socket.leave).not.toHaveBeenCalledWith(ROOM_NAME) + current.socket.emit.mockClear() + current.handlers[FILE_DOC_EVENTS.MESSAGE]( + frame(FILE_DOC_MESSAGE_TYPE.SYNC, (encoder) => + syncProtocol.writeSyncStep1(encoder, new Y.Doc()) + ) + ) + expect(current.socket.emit).toHaveBeenCalledWith( + FILE_DOC_EVENTS.MESSAGE, + expect.any(Uint8Array) + ) + } finally { + guard.mockRestore() + } + }) + + it('does not discard a rebuilt room after a delayed check of its predecessor', async () => { + mockFetchFileDocSeed.mockResolvedValue(seedResult('# Old', 'doc-old')) + const { io, left } = createIo() + const original = setup('socket-original', io) + await original.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + const checks: Array<(current: boolean) => void> = [] + const guard = vi + .spyOn(getFileDocStore(), 'isDocumentGenerationCurrent') + .mockResolvedValue(true) + .mockImplementationOnce(() => new Promise((resolve) => checks.push(resolve))) + .mockImplementationOnce(() => new Promise((resolve) => checks.push(resolve))) + try { + mockFetchFileDocSeed.mockResolvedValue(seedResult('# New', 'doc-new')) + const first = setup('socket-first-new', io) + const second = setup('socket-second-new', io) + const firstJoin = first.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 2 }) + const secondJoin = second.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 3 }) + await vi.waitFor(() => expect(checks).toHaveLength(2)) + checks[0](false) + await firstJoin + checks[1](false) + await secondJoin + + expect(joinSuccessFileId(first.socket)).toBe('file-1') + expect(joinSuccessFileId(second.socket)).toBe('file-1') + expect(left).toContainEqual({ socketId: 'socket-original', room: ROOM_NAME }) + expect(left).not.toContainEqual({ socketId: 'socket-first-new', room: ROOM_NAME }) + } finally { + guard.mockRestore() + } + }) + it('seeds once across concurrent joiners, and every one of them waits for that seed', async () => { // Keep the first seed fetch IN FLIGHT so the doc is still unseeded when the second socket joins: // that forces the dedup onto the in-flight seed rather than `isDocSeeded`. Both joins must WAIT @@ -613,16 +1500,18 @@ describe('setupWorkspaceFileDocHandlers', () => { expect(clientDoc.getText(FILE_DOC_FIELD).toString()).toBe('# From server') }) - it('marks an empty/absent-file doc seeded so clients still reach readiness', async () => { - // A genuinely absent file yields a null seed (a read error would throw, not return null). The - // relay must still flip `initialContentLoaded` so the client's `synced && seeded` gate opens. - mockFetchFileDocSeed.mockResolvedValue(null) + it('seeds an existing empty file with its accepted document identity', async () => { + mockFetchFileDocSeed.mockResolvedValue(seedResult('', 'empty-doc')) const { io } = createIo() const { socket, handlers } = setup('socket-1', io) await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) await flushMicrotasks() + expect(socket.emit).toHaveBeenCalledWith( + FILE_DOC_EVENTS.JOIN_SUCCESS, + expect.objectContaining({ docId: 'empty-doc', version: 1 }) + ) socket.emit.mockClear() handlers[FILE_DOC_EVENTS.MESSAGE]( frame(FILE_DOC_MESSAGE_TYPE.SYNC, (e) => syncProtocol.writeSyncStep1(e, new Y.Doc())) @@ -633,7 +1522,39 @@ describe('setupWorkspaceFileDocHandlers', () => { const clientDoc = new Y.Doc() applySyncReply(reply?.[1] as Uint8Array, clientDoc) expect(clientDoc.getMap(FILE_DOC_SEED.configMap).get(FILE_DOC_SEED.flag)).toBe(true) + expect(clientDoc.getMap(FILE_DOC_SEED.configMap).get(FILE_DOC_SEED.docIdKey)).toBe('empty-doc') expect(clientDoc.getText(FILE_DOC_FIELD).toString()).toBe('') + clientDoc.destroy() + }) + + it('rejects a missing seed without publishing an editable blank room and releases its lock', async () => { + mockFetchFileDocSeed.mockResolvedValueOnce(null) + const { io, sent } = createIo() + const { socket, handlers } = setup('socket-missing-seed', io) + const release = vi.spyOn(getFileDocStore(), 'releaseSeedLock') + const seed = vi.spyOn(getFileDocStore(), 'seedIfEmpty') + try { + await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + expect(socket.emit).toHaveBeenCalledWith( + FILE_DOC_EVENTS.JOIN_ERROR, + expect.objectContaining({ code: 'NOT_FOUND', retryable: false }) + ) + expect(socket.emit).not.toHaveBeenCalledWith(FILE_DOC_EVENTS.JOIN_SUCCESS, expect.anything()) + expect(socket.emit).not.toHaveBeenCalledWith(FILE_DOC_EVENTS.MESSAGE, expect.anything()) + expect(socket.join).not.toHaveBeenCalled() + expect(sent.some(({ event }) => event === FILE_DOC_EVENTS.PRESENCE)).toBe(false) + expect(seed).not.toHaveBeenCalled() + expect(release).toHaveBeenCalledOnce() + + await handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 2 }) + expect(socket.emit).toHaveBeenCalledWith( + FILE_DOC_EVENTS.JOIN_SUCCESS, + expect.objectContaining({ docId: 'doc-default' }) + ) + } finally { + release.mockRestore() + seed.mockRestore() + } }) it('makes one seed attempt and releases the guard on failure so a later join retries', async () => { @@ -918,7 +1839,8 @@ describe('setupWorkspaceFileDocHandlers', () => { FILE_DOC_EVENTS.JOIN_ERROR, expect.objectContaining({ code: 'CLIENT_ID_IN_USE' }) ) - expect(b.socket.join).not.toHaveBeenCalled() + expect(b.socket.leave).toHaveBeenCalledWith(ROOM_NAME) + expect(joinSuccessFileId(b.socket)).toBeUndefined() }) it('reclaims a client id for the SAME user reconnecting (reused Yjs client id)', async () => { @@ -1030,6 +1952,31 @@ describe('setupWorkspaceFileDocHandlers', () => { ).not.toThrow() }) + it('drops a legacy frame that cannot fit the durable stream budget', async () => { + const { io, sent } = createIo() + const a = setup('socket-oversized-legacy', io) + await a.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + sent.length = 0 + + expect(() => + a.handlers[FILE_DOC_EVENTS.MESSAGE](new Uint8Array(FILE_DOC_LIMITS.updateBytes + 65)) + ).not.toThrow() + expect(sent).toHaveLength(0) + }) + + it('preflights the inner legacy update before applying a framing-sized overflow', async () => { + const { io, sent } = createIo() + const a = setup('socket-inner-oversized-legacy', io) + await a.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) + sent.length = 0 + const oversized = frame(FILE_DOC_MESSAGE_TYPE.SYNC, (encoder) => + syncProtocol.writeUpdate(encoder, new Uint8Array(FILE_DOC_LIMITS.updateBytes + 1)) + ) + + expect(() => a.handlers[FILE_DOC_EVENTS.MESSAGE](oversized)).not.toThrow() + expect(sent).toHaveLength(0) + }) + it('drops the document when the last editor leaves, re-seeding a fresh joiner from the server', async () => { const { io } = createIo() const a = setup('socket-a', io) @@ -1053,12 +2000,22 @@ describe('setupWorkspaceFileDocHandlers', () => { let resolveFirst: (v: unknown) => void = () => {} mockAuthorizeRoom .mockReturnValueOnce(new Promise((resolve) => (resolveFirst = resolve))) - .mockResolvedValueOnce({ allowed: true, status: 200, workspacePermission: 'write' }) + .mockResolvedValueOnce({ + allowed: true, + status: 200, + workspacePermission: 'write', + workspaceId: 'ws-1', + }) const s = setup('socket-a', io) const pending = s.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) await s.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-2', clientId: 1 }) - resolveFirst({ allowed: true, status: 200, workspacePermission: 'write' }) + resolveFirst({ + allowed: true, + status: 200, + workspacePermission: 'write', + workspaceId: 'ws-1', + }) await pending // The socket is bound only to the newer file, never cross-bound to file-1. @@ -1075,7 +2032,12 @@ describe('setupWorkspaceFileDocHandlers', () => { const pending = s.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-1', clientId: 1 }) s.socket.disconnected = true cleanupFileDocForSocket('socket-a', io, true) // disconnect cleanup — no-op, nothing registered yet - resolveAuth({ allowed: true, status: 200, workspacePermission: 'write' }) + resolveAuth({ + allowed: true, + status: 200, + workspacePermission: 'write', + workspaceId: 'ws-1', + }) await pending expect(s.socket.join).not.toHaveBeenCalled() @@ -1095,7 +2057,12 @@ describe('setupWorkspaceFileDocHandlers', () => { const pending = s.handlers[FILE_DOC_EVENTS.JOIN]({ fileId: 'file-2', clientId: 1 }) // A stale leave for a DIFFERENT file must not invalidate the in-flight join. s.handlers[FILE_DOC_EVENTS.LEAVE]({ fileId: 'file-1' }) - resolveAuth({ allowed: true, status: 200, workspacePermission: 'write' }) + resolveAuth({ + allowed: true, + status: 200, + workspacePermission: 'write', + workspaceId: 'ws-1', + }) await pending expect(joinSuccessFileId(s.socket)).toBe('file-2') @@ -1119,7 +2086,12 @@ describe('setupWorkspaceFileDocHandlers', () => { // map (`undefined !== generation`) and abort the join the client actually wants. s.handlers[FILE_DOC_EVENTS.LEAVE]({ fileId: 'file-1' }) - resolveAuth({ allowed: true, status: 200, workspacePermission: 'write' }) + resolveAuth({ + allowed: true, + status: 200, + workspacePermission: 'write', + workspaceId: 'ws-1', + }) await pending expect(joinSuccessFileId(s.socket)).toBe('file-2') @@ -1241,7 +2213,8 @@ describe('setupWorkspaceFileDocHandlers', () => { ) // The rejected switch must leave file-1 intact — a is not torn out of its current document. expect(a.socket.leave).not.toHaveBeenCalledWith('workspace-file-doc:file-1') - expect(a.socket.join).not.toHaveBeenCalledWith('workspace-file-doc:file-2') + expect(a.socket.leave).toHaveBeenCalledWith('workspace-file-doc:file-2') + expect(joinSuccessFileId(a.socket)).toBe('file-1') }) it('broadcasts a server-authenticated presence roster on join, one entry per session', async () => { diff --git a/apps/realtime/src/handlers/file-doc.ts b/apps/realtime/src/handlers/file-doc.ts index 28ef6686f7a..994a19ce014 100644 --- a/apps/realtime/src/handlers/file-doc.ts +++ b/apps/realtime/src/handlers/file-doc.ts @@ -27,10 +27,15 @@ import { createLogger } from '@sim/logger' import { ROOM_MEMBERSHIP_ACTIONS, satisfiesRoomMembership } from '@sim/platform-authz/room-policy' import { FILE_DOC_EVENTS, + FILE_DOC_LEGACY_SCHEMA_VERSION, + FILE_DOC_LIMITS, FILE_DOC_MESSAGE_TYPE, + FILE_DOC_SCHEMA_VERSION, FILE_DOC_SEED, FILE_DOC_TIMEOUTS, type FileDocPresenceUser, + type FileDocUpdateAck, + type FileDocUpdatePayload, type JoinFileDocPayload, type LeaveFileDocPayload, toFileDocBytes, @@ -47,6 +52,7 @@ import * as Y from 'yjs' import { resolveAvatarUrl } from '@/handlers/avatar' import { fetchFileDocMerge, fetchFileDocPersist, fetchFileDocSeed } from '@/handlers/file-doc-app' import { + FileDocInvalidatedError, getFileDocStore, REDIS_AGENT_ORIGIN, REDIS_ORIGIN, @@ -176,7 +182,7 @@ interface FileDocRoom { agentStreamingUntil: number /** * Resolves once this room's doc reflects the file's shared stream (see {@link FileDocStore.catchUp}). - * Never rejects — the catch-up logs and gives up — so awaiting it can never fail a join. + * Rejects when replay cannot complete so the join fails closed rather than serving partial state. */ hydrated: Promise /** @@ -185,10 +191,14 @@ interface FileDocRoom { * document being assembled. A room with a join in flight is not idle. */ pendingJoins: number + /** Acknowledged updates currently waiting for their durable stream append. */ + pendingUpdates: number } /** Live documents keyed by Socket.IO room name. Module-global: one Y.Doc per file. */ const fileDocRooms = new Map() +const pendingFileDocPersists = new Set>() +const pendingFileDocUpdates = new Set>() /** socketId → its current file-doc room name (a socket edits at most one doc). */ const socketToRoomName = new Map() /** @@ -217,14 +227,40 @@ const fileDocRoom = (fileId: string): RoomRef => ({ id: fileId, }) +/** Pending admissions receive invalidations here, never document or presence frames. */ +export function fileDocAdmissionRoom(fileId: string): string { + return `file-doc-admission:${fileId}` +} + /** * A `y-protocols` transaction/awareness origin is the emitting socket id (a * string) when it came from a client, and something else (`null` / `'local'` / * `'timeout'`) for server-internal changes. Returns the socket id to exclude * from a relay, or `null` to broadcast to the whole room. */ +interface ClientUpdateOrigin { + kind: 'client-update' + socketId: string +} + +const MAX_CLIENT_UPDATE_ID_LENGTH = 128 + +function clientUpdateOrigin(socketId: string): ClientUpdateOrigin { + return { kind: 'client-update', socketId } +} + +function isClientUpdateOrigin(origin: unknown): origin is ClientUpdateOrigin { + return ( + typeof origin === 'object' && + origin !== null && + (origin as Partial).kind === 'client-update' && + typeof (origin as Partial).socketId === 'string' + ) +} + function originSocketId(origin: unknown): string | null { - return typeof origin === 'string' ? origin : null + if (typeof origin === 'string') return origin + return isClientUpdateOrigin(origin) ? origin.socketId : null } /** @@ -235,6 +271,28 @@ function originSocketId(origin: unknown): string | null { * on its own echo because the operations are already applied locally. */ const AGENT_SYNC_ORIGIN = Symbol('file-doc-agent-sync') +/** Maximum legacy framed message size: raw update budget plus small Yjs framing headroom. */ +const MAX_LEGACY_FRAME_BYTES = FILE_DOC_LIMITS.updateBytes + 64 + +/** + * Checks the inner update before readSyncMessage mutates the room: the legacy outer-frame limit + * includes framing headroom, which must not allow an update too large for the shared stream. + */ +function hasOversizedLegacyUpdate(bytes: Uint8Array): boolean { + const decoder = decoding.createDecoder(bytes) + const messageType = decoding.readVarUint(decoder) + if ( + messageType !== FILE_DOC_MESSAGE_TYPE.SYNC && + messageType !== FILE_DOC_MESSAGE_TYPE.SYNC_NO_PERSIST + ) { + return false + } + const syncType = decoding.readVarUint(decoder) + if (syncType !== syncProtocol.messageYjsSyncStep2 && syncType !== syncProtocol.messageYjsUpdate) { + return false + } + return decoding.readVarUint8Array(decoder).byteLength > FILE_DOC_LIMITS.updateBytes +} /** * Broadcast an AWARENESS frame to the room ACROSS tasks via the Socket.IO Redis adapter. Awareness @@ -295,10 +353,19 @@ function schedulePersist(name: string, room: FileDocRoom): void { * fallback before any await, so a `void flushPersist(name, room, true)` fired immediately before the * caller destroys `room.doc` never encodes a destroyed doc, and the disabled path stays authoritative. */ -async function flushPersist(name: string, room: FileDocRoom, final: boolean): Promise { +function flushPersist(name: string, room: FileDocRoom, final: boolean): Promise { + const pending = persistRoom(name, room, final).finally(() => + pendingFileDocPersists.delete(pending) + ) + pendingFileDocPersists.add(pending) + return pending +} + +async function persistRoom(name: string, room: FileDocRoom, final: boolean): Promise { // Never project a doc no user actually edited back over the file (see {@link FileDocRoom.edited}). if (!room.edited || !room.workspaceId || !room.lastEditorUserId) return const store = getFileDocStore() + const generation = docIdOf(room.doc) const workspaceId = room.workspaceId const userId = room.lastEditorUserId // Synchronous fallback capture — before any await, since the caller may destroy `room.doc` the moment @@ -321,6 +388,7 @@ async function flushPersist(name: string, room: FileDocRoom, final: boolean): Pr try { return (await store.getStreamState(name)) ?? localState } catch (streamError) { + if (streamError instanceof FileDocInvalidatedError) throw streamError // A transient Redis read must NOT drop the write when we already hold a valid local snapshot — // else the last-disconnect flush loses the session's edits as the room is torn down. But once a // reconcile has run, `localState` is NULLED (it predates the merged-in out-of-band edit), so a @@ -344,8 +412,11 @@ async function flushPersist(name: string, room: FileDocRoom, final: boolean): Pr } try { - if (!final && !(await store.tryClaimPersistWindow(name, FILE_DOC_TIMEOUTS.persistRequestMs))) + if (!(await store.isDocumentGenerationCurrent(name, generation))) return + if (!final && !(await store.tryClaimPersistWindow(name, FILE_DOC_TIMEOUTS.persistRequestMs))) { + if (fileDocRooms.get(name) === room) schedulePersist(name, room) return + } // The If-Match token: the durable content version the live doc is synced to. let ifMatch = await currentVersion() @@ -367,6 +438,7 @@ async function flushPersist(name: string, room: FileDocRoom, final: boolean): Pr // out-of-band edit. A single attempt — on conflict we STOP rather than retry (see below). const docState = await captureState() if (!docState) return // nothing seeded/authoritative to persist yet + if (!(await store.isDocumentGenerationCurrent(name, generation))) return const result = await fetchFileDocPersist(workspaceId, room.fileId, userId, docState, ifMatch) if (result.status === 'missing') return // the file was deleted; nothing to write if (result.status === 'deferred') { @@ -382,23 +454,21 @@ async function flushPersist(name: string, room: FileDocRoom, final: boolean): Pr // here means a task that exits in the moments after a write comes back holding a version older // than the file's, and — since a conflict neither writes nor advances the token — never persists // that document again. One round trip after a blob write is not a cost worth that. - await store.setSyncedVersion(name, result.version) + await store.setSyncedVersion(name, result.version, generation) return } - // status === 'conflict': the durable file advanced out-of-band since our If-Match token. We do NOT - // re-persist against the current stream: an external write commits durable BEFORE its chokepoint merge - // (`mergeEditIntoLiveFileDoc`) reaches the stream, so a re-persist landing in that window would CAS-pass - // with a stream that still lacks the external content and clobber the committed write. Instead leave the - // durable content authoritative — the chokepoint merges the change into the stream and, ONLY once it is - // actually there, advances the synced version (via the merge's own `recordVersion`); a later flush - // (a subsequent debounced persist, or the final flush) then projects the converged stream with a token - // that matches. The session's edits stay in the stream meanwhile. Deliberately do NOT advance the synced - // version here: before the stream reflects the durable content, that would let the next flush clobber it. + /** + * External writes commit before merging into the stream. Retrying or advancing the synced + * version here could overwrite content not yet merged; leave the durable file authoritative + * until the merge advances the version, then let a later flush persist the converged state. + */ logger.warn( `Persist conflict for file ${room.fileId}; durable content advanced out-of-band, left authoritative` ) } catch (error) { - logger.warn(`Persist failed for file ${room.fileId}`, { error: getErrorMessage(error) }) + logger.warn(`Persist failed for file ${room.fileId}`, { + error: getErrorMessage(error), + }) } } @@ -469,7 +539,7 @@ function awarenessUpdateClientIds(update: Uint8Array): number[] { */ function destroyRoomIfIdle(name: string) { const room = fileDocRooms.get(name) - if (!room || room.owners.size > 0 || room.pendingJoins > 0) return + if (!room || room.owners.size > 0 || room.pendingJoins > 0 || room.pendingUpdates > 0) return room.persistDeadline = null if (room.persistTimer) { clearTimeout(room.persistTimer) @@ -484,6 +554,27 @@ function destroyRoomIfIdle(name: string) { fileDocRooms.delete(name) } +/** + * Drop a seeded in-memory generation after an out-of-band durable replacement. It must not flush: the + * durable replacement is newer, and persisting this superseded document would only create a conflict. + * Existing clients are removed from the room before the next join creates and seeds a fresh document. + */ +function discardInvalidatedRoom(name: string, io: Server): void { + const room = fileDocRooms.get(name) + if (!room) return + room.persistDeadline = null + if (room.persistTimer) clearTimeout(room.persistTimer) + room.persistTimer = null + for (const socketId of room.owners.keys()) { + if (socketToRoomName.get(socketId) === name) socketToRoomName.delete(socketId) + io.in(socketId).socketsLeave(name) + } + getFileDocStore().detachRoom(name) + room.awareness.destroy() + room.doc.destroy() + fileDocRooms.delete(name) +} + /** * Flush every open, edited room's converged doc to durable markdown, AWAITING the writes. Called on * graceful shutdown (rolling deploy / scale-in) so edits since the last debounce aren't left only in the @@ -492,18 +583,17 @@ function destroyRoomIfIdle(name: string) { * process is exiting); only their durable state is secured. */ export async function flushAllFileDocRooms(): Promise { + await Promise.all([...pendingFileDocUpdates]) const flushes: Promise[] = [] for (const [name, room] of fileDocRooms) { if (room.edited) flushes.push(flushPersist(name, room, true)) } - await Promise.all(flushes) + await Promise.all([...pendingFileDocPersists, ...flushes]) } /** - * Bring a room's document to its AUTHORITATIVE state — reflecting the file's shared stream and - * carrying its seed — so the join can attach a client to a document that is already whole. Never - * rejects: a room that cannot be seeded is served unseeded, which the client's readiness deadline - * turns into its read-only fallback, exactly as an unreachable relay does. + * Waits for shared hydration and authoritative seeding before joining; failures must not expose + * an editable partial document. */ async function ensureRoomReady( name: string, @@ -513,22 +603,18 @@ async function ensureRoomReady( await room.hydrated // The room can be dropped and re-created while the catch-up is in flight (a fast open→close); the // join re-checks identity after this and abandons a stale room rather than serving from it. - if (fileDocRooms.get(name) !== room || !workspaceId) return + if (fileDocRooms.get(name) !== room) return + if (!workspaceId) throw new Error(`File document ${room.fileId} has no workspace context`) await ensureServerSeed(name, room, workspaceId) + if (fileDocRooms.get(name) === room && !isDocSeeded(room.doc)) { + throw new Error(`File document ${room.fileId} could not be seeded`) + } } /** - * Seed a room's document server-side, once: ask the app to build the seed (the file's current markdown - * → Yjs, through the exact editor engine) and apply it. No client is elected to import content. - * - * MEMOIZED on the room, so concurrent joins await the same seed instead of the second one being served - * an empty document while the first one's fetch is still in flight. Cleared when it settles: a failed - * seed is re-attempted by the next join (a genuinely empty file stays empty and needs no retry). - * - * `isDocSeeded` is the sufficient guard: content only ever reaches the doc alongside the seed flag - * (this seed, or a client's offline fallback), so an unseeded doc is genuinely empty and safe to seed. - * A genuinely empty/missing file returns `null` (a read error throws instead), so still set the flag — - * an empty doc must reach readiness, not wait forever. + * Share one authoritative seed attempt across concurrent joins so none observes an unseeded doc. + * Clear settled attempts to allow retry after transient failures. Existing empty files have a named + * seed; a missing file must fail admission rather than create an editable blank room. */ function ensureServerSeed(name: string, room: FileDocRoom, workspaceId: string): Promise { if (isDocSeeded(room.doc)) return Promise.resolve() @@ -548,6 +634,8 @@ function ensureServerSeed(name: string, room: FileDocRoom, workspaceId: string): */ const SEED_WAIT_RETRY_MS = 150 +class FileDocNotFoundError extends Error {} + async function runServerSeed(name: string, room: FileDocRoom, workspaceId: string): Promise { const store = getFileDocStore() const deadline = Date.now() + FILE_DOC_TIMEOUTS.seedRequestMs @@ -580,32 +668,23 @@ async function seedUnderLock( try { const seed = await fetchFileDocSeed(workspaceId, room.fileId) if (fileDocRooms.get(name) !== room || isDocSeeded(room.doc)) return - // Build the seed (file content + seed flag, or just the flag for an empty/missing file) and write it - // to the shared stream ATOMICALLY, iff the stream is still empty. This — NOT the seed lock — is the - // split-brain guard: two tasks racing (even both past an expired lock) can never both seed, because - // the emptiness check and the append are one Redis-side step. Publish-before-apply: the doc is marked - // seeded (via the local apply) only once the seed is durably in the stream, so a failed write leaves - // the doc unseeded and the stream empty for a clean retry. SEED_ORIGIN keeps `doc.on('update')` from - // re-publishing it. - const seedUpdate = seed?.update ?? emptySeedUpdate() - const didSeed = await store.seedIfEmpty(name, seedUpdate) - // Record the durable version the moment THIS task's seed is in the stream — BEFORE the liveness/ - // seeded guard below. Recording it only now that our seed WON (not from the fetch, before knowing who - // won) keeps it in step with the stream's actual content: a newer own-fetch version could otherwise - // shadow a peer's winning seed and let a later persist clobber an out-of-band edit. But it must not - // sit AFTER the guard: the tailer can integrate our just-appended seed during the await above, so - // `isDocSeeded` may already be true here — an early return would then leave the stream holding seed - // content with NO cluster If-Match token, and later persists would defer and strand session edits. - // Cluster-wide (Redis) so any task's persist reads it; the live room is the single-pod fallback / the - // read-through-cache seed. (No version for an empty/missing file — nothing durable to guard.) - if (didSeed && seed) { + if (!seed) throw new FileDocNotFoundError('File not found') + /** + * Publish before local apply: the atomic empty-stream append, not the expiring lock, prevents + * independent Yjs histories from entering the same room. + */ + const didSeed = await store.seedIfEmpty(name, seed.update, seed.version) + /** + * Record only our winning seed's version before the readiness guard: the tailer may already have + * applied it during the append, but persistence still needs the matching local version. + */ + if (didSeed) { const live = fileDocRooms.get(name) if (live) live.syncedVersion = Math.max(live.syncedVersion ?? 0, seed.version) - void store.setSyncedVersion(name, seed.version) } if (fileDocRooms.get(name) !== room || isDocSeeded(room.doc)) return if (didSeed) { - Y.applyUpdate(room.doc, seedUpdate, SEED_ORIGIN) + Y.applyUpdate(room.doc, seed.update, SEED_ORIGIN) } else { // A peer won the atomic append: we must NOT apply our own — a second, different-client-id seed IS // the split-brain. Read THEIRS out of the stream instead of waiting for the tailer to deliver it, @@ -613,24 +692,13 @@ async function seedUnderLock( await store.catchUp(name) } } catch (error) { + if (error instanceof FileDocNotFoundError) throw error logger.warn(`Server seed failed for file ${room.fileId} (workspace ${workspaceId})`, error) } finally { await store.releaseSeedLock(name, token) } } -/** The seed update for an empty/missing file: just the `initialContentLoaded` flag, so an empty doc - * still reaches readiness (and its emptiness is durably shared like any seed). */ -function emptySeedUpdate(): Uint8Array { - const doc = new Y.Doc() - doc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.flag, true) - try { - return Y.encodeStateAsUpdate(doc) - } finally { - doc.destroy() - } -} - /** Serializes live merges per file so overlapping calls never race the same doc (see below). */ const fileDocMergeChains = new Map>() @@ -671,18 +739,50 @@ export function applyMarkdownToLiveFileDoc( order: MergeOrder = {} ): Promise<'applied' | 'no-live-room' | 'merge-unavailable' | 'stale'> { const name = roomName(fileDocRoom(fileId)) + return serializeFileDocMutation(name, () => mergeMarkdownIntoRoom(name, fileId, markdown, order)) +} + +function serializeFileDocMutation(name: string, operation: () => Promise): Promise { const prior = fileDocMergeChains.get(name) ?? Promise.resolve() - // `.catch` so a failed prior merge doesn't reject this one — each merge is independent. - const run = prior.catch(() => {}).then(() => mergeMarkdownIntoRoom(name, fileId, markdown, order)) - fileDocMergeChains.set( - name, - run.finally(() => { + const run = prior + .catch(() => {}) + .then(operation) + .finally(() => { if (fileDocMergeChains.get(name) === run) fileDocMergeChains.delete(name) }) - ) + fileDocMergeChains.set(name, run) return run } +async function acquireFileDocMergeSlot(name: string): Promise { + const store = getFileDocStore() + let token = await store.acquireMergeSlot(name, MERGE_LOCK_TTL_MS) + for (let i = 0; !token && i < MERGE_LOCK_RETRIES; i++) { + await sleep(MERGE_LOCK_RETRY_MS) + token = await store.acquireMergeSlot(name, MERGE_LOCK_TTL_MS) + } + return token +} + +/** Serializes and version-orders an unsupported durable replacement with live Markdown merges. */ +export function invalidateLiveFileDocument( + fileId: string, + version: number +): Promise<{ status: 'applied'; docId?: string } | { status: 'stale' }> { + const name = roomName(fileDocRoom(fileId)) + return serializeFileDocMutation(name, async () => { + const store = getFileDocStore() + const token = await acquireFileDocMergeSlot(name) + if (!token) throw new Error('Live document invalidation slot is temporarily unavailable') + try { + if ((fileDocRooms.get(name)?.syncedVersion ?? 0) > version) return { status: 'stale' } + return await store.invalidateDocument(name, version) + } finally { + await store.releaseMergeSlot(name, token) + } + }) +} + async function mergeMarkdownIntoRoom( name: string, fileId: string, @@ -695,14 +795,16 @@ async function mergeMarkdownIntoRoom( // in Redis for multi-task, plus this task's room) so the persist If-Match guard treats this write as // synced rather than an out-of-band conflict. AWAITED so the version is durable before the merge lock // releases, so the next lock holder's staleness check (below) reads a consistent value. - const recordVersion = async () => { + const recordVersion = async (generation?: string) => { if (version === undefined) return const room = fileDocRooms.get(name) // Never regress the token: merges/seeds/persists all write it, so a lower value arriving out of // order must not shadow a higher one the doc already incorporates (the Redis side is guarded // identically by SET_VERSION_IF_NEWER_SCRIPT). - if (room) room.syncedVersion = Math.max(room.syncedVersion ?? 0, version) - await store.setSyncedVersion(name, version) + if (room && (generation === undefined || docIdOf(room.doc) === generation)) { + room.syncedVersion = Math.max(room.syncedVersion ?? 0, version) + } + await store.setSyncedVersion(name, version, generation) } // Order this merge on the file's version line, where `current` is the durable version the doc already @@ -723,11 +825,7 @@ async function mergeMarkdownIntoRoom( // always releases (or its lock expires) first and we acquire — never merging against a shared base // while a peer holds the lock. If somehow still unavailable, skip the live merge (copilot's durable // file write stands) rather than race. - let token = await store.acquireMergeSlot(name, MERGE_LOCK_TTL_MS) - for (let i = 0; !token && i < MERGE_LOCK_RETRIES; i++) { - await sleep(MERGE_LOCK_RETRY_MS) - token = await store.acquireMergeSlot(name, MERGE_LOCK_TTL_MS) - } + const token = await acquireFileDocMergeSlot(name) if (!token) { logger.warn(`Merge lock unavailable for file ${fileId}; skipping live merge`) return 'merge-unavailable' @@ -738,13 +836,14 @@ async function mergeMarkdownIntoRoom( const shared = await store.getSyncedVersion(name) const current = Math.max(shared ?? 0, fileDocRooms.get(name)?.syncedVersion ?? 0) if (isStale(current)) return 'stale' + const generation = await store.getDocumentGeneration(name) // Defer to an actively-streaming client: it is applying this SAME agent edit into the shared doc // frame-by-frame, so also publishing a whole-document merge here would double-write the content (the // client's private shadow never observes this merge, so it re-inserts what we added → duplication). // Still record the durable version so the persist If-Match stays correct; the client owns the bytes, // and once streaming stops the flag clears and the final durable merge lands as a near-noop. if (await store.isAgentStreaming(name)) { - await recordVersion() + await recordVersion(generation) return 'applied' } // Compute the diff against the committed SHARED state and PUBLISH it — every task with the doc @@ -752,11 +851,11 @@ async function mergeMarkdownIntoRoom( // merge reaches the live doc no matter which task the apply-edit call landed on. An empty stream // means no doc is (or was recently) live → nothing to merge into. AWAIT the publish so the diff is // durably in the stream before we release the lock (else the next task would diff a stale base). - const base = await store.getStreamState(name) + const base = await store.getStreamState(name, generation) if (!base) return 'no-live-room' const diff = await fetchFileDocMerge(fileId, base, markdown) - await store.publishAndWait(name, diff) - await recordVersion() + await store.publishAndWait(name, diff, generation) + await recordVersion(generation) return 'applied' } finally { await store.releaseMergeSlot(name, token) @@ -815,6 +914,7 @@ function getOrCreateRoom(io: Server, ref: RoomRef): FileDocRoom { agentStreamingUntil: 0, hydrated, pendingJoins: 0, + pendingUpdates: 0, } // Register synchronously BEFORE the async catch-up so a concurrent join sees this room, not a second. fileDocRooms.set(name, room) @@ -839,7 +939,8 @@ function getOrCreateRoom(io: Server, ref: RoomRef): FileDocRoom { origin !== REDIS_ORIGIN && origin !== REDIS_SNAPSHOT_ORIGIN && origin !== REDIS_AGENT_ORIGIN && - origin !== SEED_ORIGIN + origin !== SEED_ORIGIN && + !isClientUpdateOrigin(origin) ) getFileDocStore().publish(name, update, origin === AGENT_SYNC_ORIGIN) // A locally-originated agent frame (this task's stream leader) means a client is applying this agent @@ -979,10 +1080,24 @@ function handleMessage(socket: AuthenticatedSocket, io: Server, data: unknown) { const bytes = toFileDocBytes(data) if (!bytes) return + if (bytes.byteLength > MAX_LEGACY_FRAME_BYTES) { + logger.warn('Dropping an oversized legacy file-doc frame', { + socketId: socket.id, + bytes: bytes.byteLength, + }) + return + } // A malformed frame from any client must never escape as a process-level // exception; drop it and keep the relay running. try { + if (hasOversizedLegacyUpdate(bytes)) { + logger.warn('Dropping a legacy file-doc update outside the durable stream budget', { + socketId: socket.id, + bytes: bytes.byteLength, + }) + return + } const decoder = decoding.createDecoder(bytes) const messageType = decoding.readVarUint(decoder) @@ -1027,7 +1142,9 @@ function handleMessage(socket: AuthenticatedSocket, io: Server, data: unknown) { // owned by this socket. const owned = room.owners.get(socket.id) if (owned === undefined || awarenessUpdateClientIds(update).some((id) => !owned.has(id))) { - logger.warn('Dropping awareness frame for an unowned client id', { socketId: socket.id }) + logger.warn('Dropping awareness frame for an unowned client id', { + socketId: socket.id, + }) return } awarenessProtocol.applyAwarenessUpdate(room.awareness, update, socket.id) @@ -1037,7 +1154,114 @@ function handleMessage(socket: AuthenticatedSocket, io: Server, data: unknown) { logger.warn('Unknown file-doc message type', { messageType }) } } catch (error) { - logger.warn('Dropping malformed file-doc frame', { socketId: socket.id, error }) + logger.warn('Dropping malformed file-doc frame', { + socketId: socket.id, + error, + }) + } +} + +async function handleClientUpdate( + socket: AuthenticatedSocket, + io: Server, + data: unknown, + acknowledge: (result: FileDocUpdateAck) => void +): Promise { + const reject = ( + code: Extract['code'], + retryable: boolean, + updateId?: string + ) => acknowledge({ status: 'rejected', code, retryable, updateId }) + + if (typeof data !== 'object' || data === null) { + reject('INVALID_UPDATE', false) + return + } + + const candidate = data as Partial + const update = toFileDocBytes(candidate.update) + if ( + typeof candidate.fileId !== 'string' || + candidate.fileId.length === 0 || + typeof candidate.docId !== 'string' || + candidate.docId.length === 0 || + typeof candidate.updateId !== 'string' || + candidate.updateId.length === 0 || + candidate.updateId.length > MAX_CLIENT_UPDATE_ID_LENGTH || + !update || + update.byteLength === 0 || + update.byteLength > FILE_DOC_LIMITS.updateBytes + ) { + reject('INVALID_UPDATE', false, candidate.updateId) + return + } + + const name = socketToRoomName.get(socket.id) + if (!name || name !== roomName(fileDocRoom(candidate.fileId))) { + reject('NOT_JOINED', true, candidate.updateId) + return + } + const room = fileDocRooms.get(name) + if (!room) { + reject('NOT_JOINED', true, candidate.updateId) + return + } + if (!isFileDocWriteAllowed(socket, io, name)) { + reject('ACCESS_REVOKED', false, candidate.updateId) + return + } + if (docIdOf(room.doc) !== candidate.docId) { + reject('DOCUMENT_REPLACED', false, candidate.updateId) + return + } + + const validationDoc = new Y.Doc() + try { + Y.applyUpdate(validationDoc, update) + } catch (error) { + logger.warn('Dropping malformed acknowledged file-doc update', { + socketId: socket.id, + fileId: candidate.fileId, + updateId: candidate.updateId, + error, + }) + reject('INVALID_UPDATE', false, candidate.updateId) + return + } finally { + validationDoc.destroy() + } + + const editor = room.owners.get(socket.id)?.values().next().value?.userId + if (editor) room.lastEditorUserId = editor + room.pendingUpdates += 1 + try { + const store = getFileDocStore() + await store.publishClientUpdateAndWait(name, candidate.updateId, update, candidate.docId) + if ( + !(await store.isDocumentGenerationCurrent(name, candidate.docId)) || + fileDocRooms.get(name) !== room + ) { + throw new FileDocInvalidatedError() + } + Y.applyUpdate(room.doc, update, clientUpdateOrigin(socket.id)) + room.edited = true + schedulePersist(name, room) + acknowledge({ status: 'accepted', updateId: candidate.updateId }) + } catch (error) { + if (error instanceof FileDocInvalidatedError) { + reject('DOCUMENT_REPLACED', false, candidate.updateId) + return + } + logger.error('Failed to accept acknowledged file-doc update', { + socketId: socket.id, + fileId: candidate.fileId, + updateId: candidate.updateId, + error, + }) + reject('TEMPORARY_FAILURE', true, candidate.updateId) + } finally { + room.pendingUpdates -= 1 + destroyRoomIfIdle(name) } } @@ -1102,11 +1326,15 @@ export function setupWorkspaceFileDocHandlers( // awaiting authorization can't complete after the client left and register a ghost owner. A // leave for a DIFFERENT file must NOT cancel it (a document switch), mirroring workspace-files. let currentFileId: string | null = null + /** Co-mounted providers share invalidation membership until their last admission settles. */ + const pendingMemberships = new Map() - socket.on(FILE_DOC_EVENTS.JOIN, async ({ fileId, clientId }: JoinFileDocPayload) => { + socket.on(FILE_DOC_EVENTS.JOIN, async (payload: JoinFileDocPayload) => { + const { fileId, clientId } = payload // Hoisted so the catch can tell whether this join was superseded (a switch to another file) // before surfacing a retryable error for the abandoned one. let generation: number | undefined + let registered = false try { const userId = socket.userId const userName = socket.userName @@ -1142,6 +1370,17 @@ export function setupWorkspaceFileDocHandlers( emitJoinError(socket, fileId, clientId, 'Invalid join payload', 'INVALID_PAYLOAD', false) return } + if ((payload.schemaVersion ?? FILE_DOC_LEGACY_SCHEMA_VERSION) !== FILE_DOC_SCHEMA_VERSION) { + emitJoinError( + socket, + fileId, + clientId, + 'This document version is not supported', + 'SCHEMA_VERSION_MISMATCH', + false + ) + return + } // A generation represents the socket's intended FILE, not an individual provider. Co-mounted // providers for the same file must be allowed to join concurrently; switching files advances the @@ -1156,6 +1395,7 @@ export function setupWorkspaceFileDocHandlers( const room = fileDocRoom(fileId) const name = roomName(room) + const admissionName = fileDocAdmissionRoom(fileId) const authorized = await resolveRoomJoinAuth({ userId, @@ -1177,6 +1417,17 @@ export function setupWorkspaceFileDocHandlers( // awareness). Resolved here so the generation guard below also covers this await. const avatarUrl = await resolveAvatarUrl(socket, userId) + const store = getFileDocStore() + const existing = fileDocRooms.get(name) + if ( + existing && + isDocSeeded(existing.doc) && + !(await store.isDocumentGenerationCurrent(name, docIdOf(existing.doc))) && + fileDocRooms.get(name) === existing + ) { + discardInvalidatedRoom(name, io) + } + const entry = getOrCreateRoom(io, room) // The workspace the server-side persist writes back to — and what the seed is built from, so it // must be captured BEFORE the room is prepared below. @@ -1185,6 +1436,25 @@ export function setupWorkspaceFileDocHandlers( // Hold the room open across the awaits below: it has no owner until this join commits, so a // concurrent last-leave would otherwise tear down the very document being prepared. entry.pendingJoins += 1 + let subscribed = false + const isCurrentJoin = () => + !socket.disconnected && + joinGeneration.get(socket.id) === generation && + fileDocRooms.get(name) === entry + const canRegisterJoin = () => { + if (!isCurrentJoin()) return false + const permission = peekRoomPermission(userId, room) + if (satisfiesRoomMembership(permission ?? null, ROOM_TYPES.WORKSPACE_FILE_DOC)) return true + emitJoinError( + socket, + fileId, + clientId, + 'File access changed while joining', + permission === undefined ? 'JOIN_FAILED' : 'ACCESS_DENIED', + permission === undefined + ) + return false + } try { // A client is attached to a WHOLE document or to nothing. A room assembles itself from the // shared stream and the server seed, and both land in the same Y.Doc that fans every update out @@ -1209,18 +1479,40 @@ export function setupWorkspaceFileDocHandlers( emitJoinError(socket, fileId, clientId, 'Access denied to file', 'ACCESS_DENIED', false) return } - - // Abort a JOIN superseded while the room was being prepared: the socket disconnected, a newer - // JOIN (a document switch) bumped the generation, or the room was dropped and re-created. - // Registering here would leak a dead socket's room, bind the socket to the wrong document, or - // attach it to a doc no longer registered. Last await before the commit, so nothing can - // interleave between the access re-check above and the registration below. - if ( - socket.disconnected || - joinGeneration.get(socket.id) !== generation || - fileDocRooms.get(name) !== entry - ) + if (!isCurrentJoin()) return + + /** + * Watch invalidations before checking the generation, including broadcasts from another + * replica. Pending clients must not receive document or presence frames before authorization. + */ + pendingMemberships.set(name, (pendingMemberships.get(name) ?? 0) + 1) + subscribed = true + await socket.join(admissionName) + const joinedVersion = + Math.max(entry.syncedVersion ?? 0, (await store.getSyncedVersion(name)) ?? 0) || undefined + /** Adapter membership can wait; resolve access again before checking the final generation. */ + const finalPermission = await resolveCurrentRoomPermission(userId, room, FILE_DOC_ACTION) + if (!satisfiesRoomMembership(finalPermission, ROOM_TYPES.WORKSPACE_FILE_DOC)) { + emitJoinError(socket, fileId, clientId, 'Access denied to file', 'ACCESS_DENIED', false) return + } + const currentDocument = await store.isDocumentGenerationCurrent(name, docIdOf(entry.doc)) + if (!isCurrentJoin()) return + if (!currentDocument) { + emitJoinError( + socket, + fileId, + clientId, + 'Document changed while joining', + 'JOIN_FAILED', + true + ) + return + } + if (!canRegisterJoin()) return + await socket.join(name) + /** Adapter joins can wait; recheck access and liveness before ownership or synchronization. */ + if (!canRegisterJoin()) return // A client id must be owned by at most one user, or a peer could bind an active // collaborator's id and pass the per-frame ownership check to spoof/clear its caret. @@ -1282,7 +1574,7 @@ export function setupWorkspaceFileDocHandlers( } clientMap.set(clientId, { clientId, userId, userName, avatarUrl }) socketToRoomName.set(socket.id, name) - socket.join(name) + registered = true // Attribution for the server-side persist, refreshed to the actual editor on each edit in // `handleMessage`. @@ -1295,6 +1587,9 @@ export function setupWorkspaceFileDocHandlers( fileId, clientId, docId: docIdOf(entry.doc), + version: joinedVersion, + schemaVersion: FILE_DOC_SCHEMA_VERSION, + ...(store.enabled ? { acknowledgedUpdates: true as const } : {}), }) // Server-authenticated roster → everyone in the room, including this joiner. broadcastFileDocPresence(io, name, entry) @@ -1324,19 +1619,28 @@ export function setupWorkspaceFileDocHandlers( // A join that returned without registering may have left behind the room it created; drop it // if nothing else claimed it. A no-op once this join committed (the room then has an owner). destroyRoomIfIdle(name) + if (subscribed) { + const remaining = (pendingMemberships.get(name) ?? 1) - 1 + if (remaining > 0) pendingMemberships.set(name, remaining) + else { + pendingMemberships.delete(name) + await socket.leave(admissionName) + if (socketToRoomName.get(socket.id) !== name) await socket.leave(name) + } + } } } catch (error) { logger.error('Error joining file-doc room:', error) try { const name = roomName(fileDocRoom(fileId)) - socket.leave(name) - // Roll back ONLY this join's target room. cleanupFileDocForSocket keys off socketToRoomName, - // which — if the join failed before rebinding to the target (e.g. a switch that threw during - // client-id reclaim) — still points at the socket's PRIOR, valid document. Running it then - // would tear down a document the socket is validly in. So only run it when the binding - // already points at the target; otherwise the socket never registered as an owner of this - // room and the only leftover is a freshly-created empty room, dropped below. - if (socketToRoomName.get(socket.id) === name) cleanupFileDocForSocket(socket.id, io) + /** + * Roll back ownership only if this attempt committed it. A failed provisional admission must + * preserve a previous file's binding and any co-mounted provider already in the target room. + */ + if (registered && socketToRoomName.get(socket.id) === name) { + socket.leave(name) + cleanupFileDocForSocket(socket.id, io) + } destroyRoomIfIdle(name) } catch {} // Suppress the client-facing error when this join was already superseded (a switch to another @@ -1348,12 +1652,27 @@ export function setupWorkspaceFileDocHandlers( (generation !== undefined && joinGeneration.get(socket.id) !== generation) ) return - emitJoinError(socket, fileId, clientId, 'Failed to join file document', 'JOIN_FAILED', true) + if (error instanceof FileDocNotFoundError) { + emitJoinError(socket, fileId, clientId, 'File not found', 'NOT_FOUND', false) + } else { + emitJoinError(socket, fileId, clientId, 'Failed to join file document', 'JOIN_FAILED', true) + } } }) socket.on(FILE_DOC_EVENTS.MESSAGE, (data: unknown) => handleMessage(socket, io, data)) + socket.on( + FILE_DOC_EVENTS.UPDATE, + (data: unknown, acknowledge?: (result: FileDocUpdateAck) => void) => { + if (typeof acknowledge !== 'function') return + const pending = handleClientUpdate(socket, io, data, acknowledge) + .catch((error) => logger.error('Unhandled acknowledged file-doc update failure:', error)) + .finally(() => pendingFileDocUpdates.delete(pending)) + pendingFileDocUpdates.add(pending) + } + ) + socket.on(FILE_DOC_EVENTS.LEAVE, (payload?: LeaveFileDocPayload) => { try { // Cancel an in-flight join whose file the client is now leaving (or an unscoped leave): a diff --git a/apps/realtime/src/index.ts b/apps/realtime/src/index.ts index 80663141334..29240c4e656 100644 --- a/apps/realtime/src/index.ts +++ b/apps/realtime/src/index.ts @@ -6,6 +6,7 @@ import { createSocketIOServer, shutdownSocketIOAdapter } from '@/config/socket' import { assertSchemaCompatibility } from '@/database/preflight' import { env } from '@/env' import { setupAllHandlers } from '@/handlers' +import { waitForConnectionCleanup } from '@/handlers/connection' import { flushAllFileDocRooms } from '@/handlers/file-doc' import { getFileDocStore, initFileDocStore } from '@/handlers/file-doc-store' import { type AuthenticatedSocket, authenticateSocket } from '@/middleware/auth' @@ -121,6 +122,11 @@ async function main() { shuttingDown = true logger.info('Shutting down Socket.IO server...') + const shutdownTimer = setTimeout(() => { + logger.error('Forced shutdown after timeout') + process.exit(1) + }, SHUTDOWN_TIMEOUT_MS) + accessRevalidation.stop() // Flush open collaborative docs to durable markdown BEFORE tearing down Redis/the store — the @@ -132,6 +138,15 @@ async function main() { logger.error('Error flushing collaborative documents on shutdown:', error) } + /** Transport closure permits reconnection; a namespace DISCONNECT intentionally does not. */ + try { + await io.close() + await waitForConnectionCleanup() + await flushAllFileDocRooms() + } catch (error) { + logger.error('Error draining socket connections on shutdown:', error) + } + try { await roomManager.shutdown() logger.info('RoomManager shutdown complete') @@ -151,24 +166,9 @@ async function main() { logger.error('Error during FileDocStore shutdown:', error) } - // Close local client connections so `httpServer.close()` can complete its callback and exit - // gracefully — otherwise open websockets keep it hanging until the forced-exit timer below. - // Local-only: a rolling deploy must not disconnect clients pinned to other pods. - try { - io.local.disconnectSockets(true) - } catch (error) { - logger.error('Error disconnecting sockets on shutdown:', error) - } - - httpServer.close(() => { - logger.info('Socket.IO server closed') - process.exit(0) - }) - - setTimeout(() => { - logger.error('Forced shutdown after timeout') - process.exit(1) - }, SHUTDOWN_TIMEOUT_MS) + clearTimeout(shutdownTimer) + logger.info('Socket.IO server closed') + process.exit(0) } process.on('SIGINT', shutdown) diff --git a/apps/realtime/src/routes/http.test.ts b/apps/realtime/src/routes/http.test.ts index 725341deac9..e2fe3a476d4 100644 --- a/apps/realtime/src/routes/http.test.ts +++ b/apps/realtime/src/routes/http.test.ts @@ -1,6 +1,15 @@ import type { IncomingMessage, ServerResponse } from 'http' import { describe, expect, it, vi } from 'vitest' import type { IRoomManager } from '@/rooms' + +const { mockInvalidateDocument } = vi.hoisted(() => ({ mockInvalidateDocument: vi.fn() })) + +vi.mock('@/handlers/file-doc', () => ({ + applyMarkdownToLiveFileDoc: vi.fn(), + fileDocAdmissionRoom: (fileId: string) => `file-doc-admission:${fileId}`, + invalidateLiveFileDocument: mockInvalidateDocument, +})) + import { createHttpHandler } from '@/routes/http' function createMocks(req: Partial) { @@ -8,9 +17,13 @@ function createMocks(req: Partial) { const writeHead = vi.fn() const end = vi.fn() const logger = { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() } + const emit = vi.fn() + const to = vi.fn(() => ({ emit })) const roomManager = { + io: { to }, getTotalActiveConnections: vi.fn().mockResolvedValue(0), isReady: vi.fn().mockReturnValue(true), + emitToRoom: vi.fn(), } as unknown as IRoomManager return { @@ -20,9 +33,27 @@ function createMocks(req: Partial) { setHeader, writeHead, end, + roomManager, + to, + emit, } } +function requestWithBody(url: string, body: unknown): Partial { + const text = JSON.stringify(body) + const request = { + method: 'POST', + url, + headers: { 'x-api-key': 'test-internal-api-secret-at-least-32-chars' }, + on(event: string, callback: (value?: Buffer) => void) { + if (event === 'data') callback(Buffer.from(text)) + if (event === 'end') callback() + return request + }, + } + return request as unknown as Partial +} + describe('createHttpHandler', () => { /** * `/health` is the only route on this server that returns 200 with a body, so @@ -58,4 +89,43 @@ describe('createHttpHandler', () => { expect(writeHead).toHaveBeenCalledWith(200, { 'Content-Type': 'application/json' }) }) + + it('invalidates the shared generation before notifying every open editor', async () => { + mockInvalidateDocument.mockResolvedValueOnce({ status: 'applied', docId: 'old-document' }) + const { handler, req, res, writeHead, to, emit } = createMocks( + requestWithBody('/api/file-doc/invalidate', { fileId: 'file-1', version: 100 }) + ) + + await handler(req, res) + + expect(mockInvalidateDocument).toHaveBeenCalledWith('file-1', 100) + expect(to).toHaveBeenCalledWith(['workspace-file-doc:file-1', 'file-doc-admission:file-1']) + expect(emit).toHaveBeenCalledWith( + 'file-doc-invalidated', + expect.objectContaining({ fileId: 'file-1', version: 100, docId: 'old-document' }) + ) + expect(mockInvalidateDocument.mock.invocationCallOrder[0]).toBeLessThan( + emit.mock.invocationCallOrder[0] + ) + expect(writeHead).toHaveBeenCalledWith(200, { 'Content-Type': 'application/json' }) + }) + + it('does not evict editors for a superseded invalidation', async () => { + mockInvalidateDocument.mockResolvedValueOnce({ status: 'stale' }) + const { handler, req, res, end, roomManager, to } = createMocks( + requestWithBody('/api/file-doc/invalidate', { fileId: 'file-1', version: 100 }) + ) + await handler(req, res) + expect(roomManager.emitToRoom).not.toHaveBeenCalled() + expect(to).not.toHaveBeenCalled() + expect(end).toHaveBeenCalledWith(JSON.stringify({ status: 'stale' })) + }) + + it('requires a durable version for invalidation', async () => { + const { handler, req, res, writeHead } = createMocks( + requestWithBody('/api/file-doc/invalidate', { fileId: 'file-1' }) + ) + await handler(req, res) + expect(writeHead).toHaveBeenCalledWith(400, { 'Content-Type': 'application/json' }) + }) }) diff --git a/apps/realtime/src/routes/http.ts b/apps/realtime/src/routes/http.ts index 19d2401e37e..da26b68aa3b 100644 --- a/apps/realtime/src/routes/http.ts +++ b/apps/realtime/src/routes/http.ts @@ -1,8 +1,13 @@ import type { IncomingMessage, ServerResponse } from 'http' -import { WORKSPACE_LIST_ROOM_TYPES } from '@sim/realtime-protocol/rooms' +import { FILE_DOC_EVENTS, type FileDocInvalidated } from '@sim/realtime-protocol/file-doc' +import { ROOM_TYPES, roomName, WORKSPACE_LIST_ROOM_TYPES } from '@sim/realtime-protocol/rooms' import { safeCompare } from '@sim/security/compare' import { env } from '@/env' -import { applyMarkdownToLiveFileDoc } from '@/handlers/file-doc' +import { + applyMarkdownToLiveFileDoc, + fileDocAdmissionRoom, + invalidateLiveFileDocument, +} from '@/handlers/file-doc' import { type IRoomManager, WorkflowRoomService } from '@/rooms' interface Logger { @@ -207,7 +212,7 @@ export function createHttpHandler(roomManager: IRoomManager, logger: Logger) { version: typeof version === 'number' ? version : undefined, }) res.writeHead(200, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ applied: result === 'applied' })) + res.end(JSON.stringify({ applied: result === 'applied', status: result })) } catch (error) { logger.error('Error applying copilot edit to live file-doc:', error) sendError(res, 'Failed to apply edit to live document') @@ -215,6 +220,35 @@ export function createHttpHandler(roomManager: IRoomManager, logger: Logger) { return } + if (req.method === 'POST' && req.url === '/api/file-doc/invalidate') { + try { + const body = await readRequestBody(req) + const { fileId, version } = JSON.parse(body) + if (!isNonEmptyString(fileId)) return sendError(res, 'Invalid fileId', 400) + if (!Number.isSafeInteger(version) || version <= 0) { + return sendError(res, 'Invalid version', 400) + } + const room = { type: ROOM_TYPES.WORKSPACE_FILE_DOC, id: fileId } as const + const result = await invalidateLiveFileDocument(fileId, version) + const payload: FileDocInvalidated = { + fileId, + version, + ...(result.status === 'applied' && result.docId ? { docId: result.docId } : {}), + message: 'This file changed outside the editor. Reload to continue editing.', + } + if (result.status === 'applied') + roomManager.io + .to([roomName(room), fileDocAdmissionRoom(fileId)]) + .emit(FILE_DOC_EVENTS.INVALIDATED, payload) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ status: result.status })) + } catch (error) { + logger.error('Error invalidating live file-doc:', error) + sendError(res, 'Failed to invalidate live document') + } + return + } + res.writeHead(404, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ error: 'Not found' })) } diff --git a/apps/sim/app/(auth)/auth-redirect.test.ts b/apps/sim/app/(auth)/auth-redirect.test.ts index 93a94dd6eae..5361878dec2 100644 --- a/apps/sim/app/(auth)/auth-redirect.test.ts +++ b/apps/sim/app/(auth)/auth-redirect.test.ts @@ -32,7 +32,7 @@ describe('resolvePostSignupDestination', () => { it('never routes to verify when no mail provider is configured', () => { expect( resolvePostSignupDestination({ emailVerificationEnabled: false, redirectUrl: '' }) - ).toEqual({ kind: 'workspace' }) + ).toEqual({ kind: 'entry' }) }) it('preserves the callback URL when verification is not enforceable', () => { diff --git a/apps/sim/app/(auth)/auth-redirect.ts b/apps/sim/app/(auth)/auth-redirect.ts index 269528c0bb1..c487f5a1c99 100644 --- a/apps/sim/app/(auth)/auth-redirect.ts +++ b/apps/sim/app/(auth)/auth-redirect.ts @@ -1,3 +1,5 @@ +import { APP_ENTRY_PATH } from '@/lib/navigation/paths' + /** * Where the user goes once authentication finishes, carried across the login → * signup → verify hops. Written only after `validateCallbackUrl` accepts it, and @@ -8,19 +10,22 @@ export const POST_AUTH_REDIRECT_STORAGE_KEY = 'postAuthRedirectUrl' /** Route the verify hop lives at, entered only from signup. */ export const VERIFY_FROM_SIGNUP_ROUTE = '/verify?fromSignup=true' -/** Default post-auth destination when no callback URL was carried in. */ -export const DEFAULT_POST_AUTH_ROUTE = '/workspace' +/** + * Default post-auth destination when no callback URL was carried in: the app + * entry, which resolves to the viewer's organization or their workspaces. + */ +export const DEFAULT_POST_AUTH_ROUTE = APP_ENTRY_PATH /** * Where a successful email signup goes next. * - `verify`: the verification hop, which owns the post-auth redirect from there * - `redirect`: the validated callback URL the visitor arrived with - * - `workspace`: the default destination + * - `entry`: the default destination, {@link DEFAULT_POST_AUTH_ROUTE} */ export type PostSignupDestination = | { kind: 'verify' } | { kind: 'redirect'; url: string } - | { kind: 'workspace' } + | { kind: 'entry' } interface PostSignupDestinationParams { /** The server-derived effective flag — verification enabled AND deliverable. */ @@ -40,7 +45,7 @@ export function resolvePostSignupDestination({ redirectUrl, }: PostSignupDestinationParams): PostSignupDestination { if (emailVerificationEnabled) return { kind: 'verify' } - return redirectUrl ? { kind: 'redirect', url: redirectUrl } : { kind: 'workspace' } + return redirectUrl ? { kind: 'redirect', url: redirectUrl } : { kind: 'entry' } } /** The raw redirect-carrying params, as read from a URL on client or server. */ @@ -74,6 +79,17 @@ export function resolveAuthRedirect({ redirect, callbackUrl, inviteFlow }: AuthR } } +/** OAuth reauthentication must reach the form even when an older session cookie exists. */ +export function isOAuthAuthorizationCallback(callbackUrl: string, origin: string): boolean { + if (!callbackUrl) return false + try { + const callback = new URL(callbackUrl, origin) + return callback.origin === origin && callback.pathname === '/api/auth/oauth2/authorize' + } catch { + return false + } +} + interface AuthCrossLinkParams { /** Validated post-auth destination to carry over, or null to drop it. */ callbackUrl: string | null diff --git a/apps/sim/app/(auth)/components/auth-header.tsx b/apps/sim/app/(auth)/components/auth-header.tsx index 96cfceceda5..803e1b0ec57 100644 --- a/apps/sim/app/(auth)/components/auth-header.tsx +++ b/apps/sim/app/(auth)/components/auth-header.tsx @@ -2,7 +2,7 @@ import type { ReactNode } from 'react' interface AuthHeaderProps { title: string - description: ReactNode + description?: ReactNode } /** @@ -15,7 +15,9 @@ export function AuthHeader({ title, description }: AuthHeaderProps) { return (

{title}

-

{description}

+ {description != null && ( +

{description}

+ )}
) } diff --git a/apps/sim/app/(auth)/components/social-login-buttons.tsx b/apps/sim/app/(auth)/components/social-login-buttons.tsx index c200d86bd11..37df7815ed7 100644 --- a/apps/sim/app/(auth)/components/social-login-buttons.tsx +++ b/apps/sim/app/(auth)/components/social-login-buttons.tsx @@ -6,6 +6,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { GithubIcon, GoogleIcon, MicrosoftIcon } from '@/components/icons' import { client } from '@/lib/auth/auth-client' +import { DEFAULT_POST_AUTH_ROUTE } from '@/app/(auth)/auth-redirect' import { AUTH_BUTTON_CLASS } from '@/app/(auth)/components/constants' const logger = createLogger('SocialLoginButtons') @@ -22,7 +23,7 @@ export function SocialLoginButtons({ githubAvailable, googleAvailable, microsoftAvailable, - callbackURL = '/workspace', + callbackURL = DEFAULT_POST_AUTH_ROUTE, children, }: SocialLoginButtonsProps) { const [isGithubLoading, setIsGithubLoading] = useState(false) diff --git a/apps/sim/app/(auth)/login/login-form.tsx b/apps/sim/app/(auth)/login/login-form.tsx index cfe0b1403b8..c2dafcb06ca 100644 --- a/apps/sim/app/(auth)/login/login-form.tsx +++ b/apps/sim/app/(auth)/login/login-form.tsx @@ -22,7 +22,7 @@ import { validateCallbackUrl } from '@/lib/core/security/input-validation' import { getBaseUrl } from '@/lib/core/utils/urls' import { quickValidateEmail } from '@/lib/messaging/email/validation' import { captureClientEvent } from '@/lib/posthog/client' -import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect' +import { buildAuthCrossLink, DEFAULT_POST_AUTH_ROUTE } from '@/app/(auth)/auth-redirect' import { AuthDivider, AuthField, @@ -108,7 +108,7 @@ export default function LoginPage({ invalidCallbackRef.current = true logger.warn('Invalid callback URL detected and blocked:', { url: callbackUrlParam }) } - const callbackUrl = isValidCallbackUrl ? callbackUrlParam! : '/workspace' + const callbackUrl = isValidCallbackUrl ? callbackUrlParam! : DEFAULT_POST_AUTH_ROUTE const isInviteFlow = searchParams?.get('invite_flow') === 'true' const signupHref = buildAuthCrossLink('/signup', { callbackUrl: isValidCallbackUrl ? callbackUrl : null, diff --git a/apps/sim/app/(auth)/oauth/consent/consent-view.test.tsx b/apps/sim/app/(auth)/oauth/consent/consent-view.test.tsx new file mode 100644 index 00000000000..243adcf8287 --- /dev/null +++ b/apps/sim/app/(auth)/oauth/consent/consent-view.test.tsx @@ -0,0 +1,123 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ consent: vi.fn(), signOut: vi.fn() })) + +vi.mock('@/lib/auth/auth-client', () => ({ + client: { oauth2: { consent: mocks.consent }, signOut: mocks.signOut }, +})) + +import { OAuthConsentView } from '@/app/(auth)/oauth/consent/consent-view' +import { oauthProviderKeys } from '@/hooks/queries/oauth-provider' + +let root: Root +let container: HTMLDivElement +let queryClient: QueryClient + +function button(label: string): HTMLButtonElement { + const found = Array.from(container.querySelectorAll('button')).find( + (element) => element.textContent === label + ) + if (!found) throw new Error(`Missing button: ${label}`) + return found +} + +async function click(label: string) { + await act(async () => { + button(label).click() + await vi.advanceTimersByTimeAsync(1) + }) +} + +describe('OAuth consent view', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + queryClient.setQueryData(oauthProviderKeys.client('sim-cli', 'request'), { + clientId: 'sim-cli', + name: 'Sim CLI', + }) + container = document.createElement('div') + root = createRoot(container) + act(() => + root.render( + + + + ) + ) + }) + + afterEach(() => { + act(() => root.unmount()) + queryClient.clear() + vi.useRealTimers() + }) + + it('shows the account below the decisions without a CLI destination sentence', () => { + expect(container.querySelector('h1')?.textContent).toBe('Authorize Sim CLI') + expect(container.textContent).toContain('Continuing as test@example.com.') + expect(container.textContent).not.toContain('Returns to this computer.') + expect( + Array.from(container.querySelectorAll('button')).map((element) => element.textContent) + ).toEqual(['Allow', 'Deny', 'Use another account']) + expect(container.querySelectorAll('li')).toHaveLength(2) + expect(button('Allow').disabled).toBe(false) + expect(button('Deny').disabled).toBe(false) + }) + + it('keeps every decision disabled while consent is in flight', async () => { + mocks.consent.mockReturnValue(new Promise(() => {})) + await click('Allow') + expect(mocks.consent).toHaveBeenCalledExactlyOnceWith({ accept: true }) + expect( + Array.from(container.querySelectorAll('button')).every((element) => element.disabled) + ).toBe(true) + expect(container.textContent).toContain('Authorizing') + }) + + it('shows protocol errors and allows retrying the decision', async () => { + mocks.consent.mockResolvedValue({ error: { message: 'The request has expired.' } }) + await click('Allow') + expect(container.querySelector('[role="alert"]')?.textContent).toBe('The request has expired.') + expect(button('Allow').disabled).toBe(false) + expect(button('Use another account').disabled).toBe(false) + }) + + it('does not allow consent during sign-out and surfaces a failure under the original account', async () => { + let finish: (value: { error: { message: string } }) => void = () => {} + mocks.signOut.mockReturnValue( + new Promise((resolve) => { + finish = resolve + }) + ) + await click('Use another account') + expect(button('Allow').disabled).toBe(true) + expect(button('Deny').disabled).toBe(true) + expect(button('Signing out…').disabled).toBe(true) + await act(async () => { + finish({ error: { message: 'Unable to end this session.' } }) + await vi.advanceTimersByTimeAsync(1) + }) + expect(container.querySelector('[role="alert"]')?.textContent).toBe( + 'Unable to end this session.' + ) + expect(container.textContent).toContain('Continuing as test@example.com.') + expect(button('Allow').disabled).toBe(false) + expect(mocks.consent).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/(auth)/oauth/consent/consent-view.tsx b/apps/sim/app/(auth)/oauth/consent/consent-view.tsx new file mode 100644 index 00000000000..b083ef34f92 --- /dev/null +++ b/apps/sim/app/(auth)/oauth/consent/consent-view.tsx @@ -0,0 +1,198 @@ +'use client' + +import { Chip } from '@sim/emcn' +import { Check } from '@sim/emcn/icons' +import { getErrorMessage } from '@sim/utils/errors' +import { + OAUTH_SCOPE_DESCRIPTIONS, + SIM_CLI_CLIENT_ID, + visibleOAuthScopes, +} from '@/lib/auth/oauth-provider' +import { + AuthFormMessage, + AuthHeader, + AuthSubmitButton, + AuthTextLink, +} from '@/app/(auth)/components' +import { AUTH_BUTTON_CLASS } from '@/app/(auth)/components/constants' +import { OAuthConsentLoading } from '@/app/(auth)/oauth/consent/loading' +import { + useOAuthConsent, + useOAuthPublicClient, + useOAuthSwitchAccount, +} from '@/hooks/queries/oauth-provider' + +export type OAuthConsentRefusal = 'expired' | 'missing' | 'tampered' | 'unsigned' + +const REFUSAL_MESSAGES: Record = { + expired: 'This authorization request has expired. Start sign-in again from the app.', + missing: 'The authorization request is missing its client identifier.', + tampered: 'This authorization request was altered on its way here.', + unsigned: 'This authorization request did not come from Sim.', +} + +interface OAuthConsentViewProps { + /** Set when the page already knows the request is not a real one. */ + refusal: OAuthConsentRefusal | null + clientId: string | null + /** Isolates cached client metadata to every parameter in this signed request. */ + authorizationRequestKey: string | null + scope: string | null + /** Where the code will be sent, shown so a lookalike app is visible as one. */ + redirectUri: string | null + /** The signed-in account the grant will belong to. */ + email: string +} + +/** + * Shows the redirect host beside the app name to help identify impersonation; + * names loopback callbacks as this computer. + */ +function describeDestination(redirectUri: string | null): string | null { + if (!redirectUri) return null + try { + const { hostname } = new URL(redirectUri) + return hostname === '127.0.0.1' || hostname === '[::1]' || hostname === '::1' + ? 'this computer' + : hostname + } catch { + return null + } +} + +/** + * Always names the app and account, including for the CLI, so users can + * recognize relayed authorization attempts. + */ +export function OAuthConsentView({ + refusal, + clientId, + authorizationRequestKey, + scope, + redirectUri, + email, +}: OAuthConsentViewProps) { + const client = useOAuthPublicClient(clientId ?? undefined, authorizationRequestKey ?? undefined) + const consent = useOAuthConsent() + const switchAccount = useOAuthSwitchAccount() + + /** Refuses clients Sim cannot name because URL metadata alone is untrusted. */ + const reason: OAuthConsentRefusal | null = refusal ?? (clientId ? null : 'missing') + if (reason || client.isError) { + return ( + + ) + } + + if (client.isPending) return + + const isCli = clientId === SIM_CLI_CLIENT_ID + /** Uses only the server-registered name; the client ID comes from the URL. */ + const appName = client.data?.name?.trim() + if (!appName) { + return ( + + ) + } + const scopes = visibleOAuthScopes((scope ?? '').split(' ').filter(Boolean)) + const destination = describeDestination(redirectUri) + const isPending = + consent.isPending || consent.isSuccess || switchAccount.isPending || switchAccount.isSuccess + + const decide = (accept: boolean) => { + switchAccount.reset() + consent.mutate(accept, { + onSuccess: (url) => { + window.location.assign(url) + }, + }) + } + + const changeAccount = () => { + consent.reset() + switchAccount.mutate(undefined, { + onSuccess: () => { + const params = new URLSearchParams(window.location.search) + params.set('prompt', 'login consent') + window.location.assign(`/oauth/sign-in?${params.toString()}`) + }, + }) + } + + return ( +
+ +
+ {scopes.length > 0 && ( +
    + {scopes.map((item) => ( +
  • +
  • + ))} +
+ )} + decide(true)} + > + Allow + + decide(false)} + > + {consent.isPending && consent.variables === false ? 'Declining…' : 'Deny'} + + {!isCli && destination && ( +

+ Returns to {destination}. +

+ )} + {(consent.isError || switchAccount.isError) && ( +
+ + {getErrorMessage( + consent.error ?? switchAccount.error, + 'Something went wrong. Please try again.' + )} + +
+ )} +

+ Continuing as {email}.{' '} + + {switchAccount.isPending ? 'Signing out…' : 'Use another account'} + +

+
+
+ ) +} diff --git a/apps/sim/app/(auth)/oauth/consent/loading.tsx b/apps/sim/app/(auth)/oauth/consent/loading.tsx new file mode 100644 index 00000000000..ec26b7da3e8 --- /dev/null +++ b/apps/sim/app/(auth)/oauth/consent/loading.tsx @@ -0,0 +1,17 @@ +import { Loader } from '@sim/emcn' +import { AuthHeader } from '@/app/(auth)/components' + +export function OAuthConsentLoading() { + return ( +
+ +
+
+
+ ) +} + +export default function OAuthConsentRouteLoading() { + return +} diff --git a/apps/sim/app/(auth)/oauth/consent/page.tsx b/apps/sim/app/(auth)/oauth/consent/page.tsx new file mode 100644 index 00000000000..50956b2fc3d --- /dev/null +++ b/apps/sim/app/(auth)/oauth/consent/page.tsx @@ -0,0 +1,63 @@ +import type { Metadata } from 'next' +import { redirect } from 'next/navigation' +import type { SearchParams } from 'nuqs/server' +import { getSession } from '@/lib/auth' +import { isAuthDisabled } from '@/lib/core/config/env-flags' +import { OAuthConsentView } from '@/app/(auth)/oauth/consent/consent-view' +import { oauthConsentSearchParamsCache } from '@/app/(auth)/oauth/consent/search-params' + +export const metadata: Metadata = { + title: 'Authorize app', + robots: { index: false, follow: false }, +} + +export const dynamic = 'force-dynamic' + +/** + * Renders the plugin's signed request; signed-out visitors restart through the + * login bridge to obtain a fresh authorization query. + */ +export default async function OAuthConsentPage({ + searchParams, +}: { + searchParams: Promise +}) { + if (isAuthDisabled) redirect('/') + + const [session, raw] = await Promise.all([getSession(), searchParams]) + + if (!session?.user) { + const query = new URLSearchParams() + for (const [key, value] of Object.entries(raw)) { + if (typeof value === 'string') query.set(key, value) + } + redirect(`/oauth/sign-in?${query.toString()}`) + } + + /** + * Repeated fields can make displayed consent diverge from the signed request; + * unsigned requests never passed through the authorization endpoint. + */ + const tampered = Object.entries(raw).some( + ([key, value]) => key !== 'ba_param' && Array.isArray(value) + ) + const unsigned = typeof raw.sig !== 'string' + const expiresAtSeconds = typeof raw.exp === 'string' ? Number(raw.exp) : Number.NaN + const expired = !Number.isFinite(expiresAtSeconds) || expiresAtSeconds * 1000 < Date.now() + const refusal = tampered ? 'tampered' : unsigned ? 'unsigned' : expired ? 'expired' : null + const params = refusal ? null : oauthConsentSearchParamsCache.parse(raw) + const authorizationRequestKey = refusal ? null : JSON.stringify(raw) + + return ( +
+ +
+ ) +} diff --git a/apps/sim/app/(auth)/oauth/consent/search-params.ts b/apps/sim/app/(auth)/oauth/consent/search-params.ts new file mode 100644 index 00000000000..1b832ef36d8 --- /dev/null +++ b/apps/sim/app/(auth)/oauth/consent/search-params.ts @@ -0,0 +1,13 @@ +import { createSearchParamsCache, parseAsString } from 'nuqs/server' + +/** + * Read once for display; the auth client forwards the original signed query. + * Nullable parsers preserve missing identifiers for rejection. + */ +const oauthConsentParsers = { + client_id: parseAsString, + scope: parseAsString, + redirect_uri: parseAsString, +} as const + +export const oauthConsentSearchParamsCache = createSearchParamsCache(oauthConsentParsers) diff --git a/apps/sim/app/(auth)/oauth/sign-in/route.test.ts b/apps/sim/app/(auth)/oauth/sign-in/route.test.ts new file mode 100644 index 00000000000..d66a51b7d60 --- /dev/null +++ b/apps/sim/app/(auth)/oauth/sign-in/route.test.ts @@ -0,0 +1,168 @@ +/** + * @vitest-environment node + */ +import { createEnvMock, envFlagsMock } from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const flags = vi.hoisted(() => ({ + authDisabled: false, + registrationDisabled: false, + appUrl: 'https://sim.test', +})) + +vi.mock('@/lib/core/config/env-flags', () => ({ + ...envFlagsMock, + get isAuthDisabled() { + return flags.authDisabled + }, + get isRegistrationDisabled() { + return flags.registrationDisabled + }, +})) + +vi.mock('@/lib/core/config/env', () => { + const mock = createEnvMock({ NEXT_PUBLIC_APP_URL: 'https://sim.test' }) + return { + ...mock, + getEnv: (key: string) => (key === 'NEXT_PUBLIC_APP_URL' ? flags.appUrl : mock.getEnv(key)), + } +}) + +vi.unmock('@/lib/core/utils/urls') + +import { GET } from '@/app/(auth)/oauth/sign-in/route' +import { proxy } from '@/proxy' + +function request(query: string): NextRequest { + return new NextRequest(`https://sim.test/oauth/sign-in?${query}`) +} + +function redirectParts(response: Response): { destination: URL; callback: URL } { + const destination = new URL(response.headers.get('location') as string) + const callbackUrl = destination.searchParams.get('callbackUrl') + if (!callbackUrl) throw new Error('redirect did not carry a callbackUrl') + return { destination, callback: new URL(callbackUrl, destination.origin) } +} + +describe('OAuth login bridge', () => { + beforeEach(() => { + flags.authDisabled = false + flags.registrationDisabled = false + flags.appUrl = 'https://sim.test' + }) + + it.each([true, false])( + 'keeps the configured auth origin when Next normalizes loopback hosts (authDisabled=%s)', + async (authDisabled) => { + flags.authDisabled = authDisabled + flags.appUrl = 'http://127.0.0.1:37488' + const incoming = new NextRequest(`${flags.appUrl}/oauth/sign-in?client_id=sim-cli`) + expect(incoming.nextUrl.origin).toBe('http://localhost:37488') + + const response = await GET(incoming) + const destination = new URL(response.headers.get('location')!) + expect(destination.origin).toBe(flags.appUrl) + expect(destination.pathname).toBe(authDisabled ? '/' : '/signup') + if (!authDisabled) expect(redirectParts(response).callback.origin).toBe(flags.appUrl) + } + ) + + it('consumes prompt=login and preserves a later consent prompt', async () => { + const response = await GET( + request( + 'client_id=sim-cli&redirect_uri=http%3A%2F%2F127.0.0.1%2Fcallback&prompt=login%20consent&sig=signed&ba_iat=1' + ) + ) + const { destination, callback } = redirectParts(response) + + expect(response.status).toBe(302) + expect(destination.pathname).toBe('/login') + expect(callback.pathname).toBe('/api/auth/oauth2/authorize') + expect(callback.searchParams.get('prompt')).toBe('consent') + expect(callback.searchParams.get('client_id')).toBe('sim-cli') + expect(callback.searchParams.has('sig')).toBe(false) + expect(callback.searchParams.has('ba_iat')).toBe(false) + }) + + it('consumes prompt=create after directing the user through signup', async () => { + const response = await GET(request('client_id=sim-cli&prompt=create')) + const { destination, callback } = redirectParts(response) + + expect(destination.pathname).toBe('/signup') + expect(callback.searchParams.has('prompt')).toBe(false) + }) + + it('uses login when registration is disabled and hides OAuth when authentication is disabled', async () => { + flags.registrationDisabled = true + const enabled = await GET(request('client_id=sim-cli')) + expect(redirectParts(enabled).destination.pathname).toBe('/login') + + flags.authDisabled = true + const disabled = await GET(request('client_id=sim-cli')) + expect(disabled.status).toBe(302) + expect(new URL(disabled.headers.get('location') as string).pathname).toBe('/') + }) + + it.each([ + ['login consent', '/login', 'consent'], + ['create', '/signup', null], + ['', '/signup', null], + ])('reaches the form with an existing session for prompt=%s', async (prompt, path, remaining) => { + const authorize = new URLSearchParams({ + client_id: 'sim-cli', + response_type: 'code', + redirect_uri: 'http://127.0.0.1:5187/callback', + code_challenge: 'challenge', + code_challenge_method: 'S256', + state: 'request-state', + prompt, + sig: 'signed-query', + }) + const { destination, callback } = redirectParts(await GET(request(authorize.toString()))) + const response = await proxy( + new NextRequest(destination, { + headers: { cookie: 'better-auth.session_token=existing.session' }, + }) + ) + + expect(destination.pathname).toBe(path) + expect(response.headers.get('location')).toBeNull() + expect(response.headers.get('x-middleware-next')).toBe('1') + expect(response.headers.get('content-security-policy')).toContain("default-src 'self'") + expect(callback.searchParams.get('state')).toBe('request-state') + expect(callback.searchParams.get('code_challenge')).toBe('challenge') + expect(callback.searchParams.get('redirect_uri')).toBe('http://127.0.0.1:5187/callback') + expect(callback.searchParams.get('prompt')).toBe(remaining) + }) + + it.each(['/login', '/signup'])('preserves ordinary authenticated %s redirects', async (path) => { + for (const callbackUrl of [ + '', + '/workspace/workspace-1', + 'https://other.test/api/auth/oauth2/authorize', + ]) { + const destination = new URL(path, 'https://sim.test') + if (callbackUrl) destination.searchParams.set('callbackUrl', callbackUrl) + const response = await proxy( + new NextRequest(destination, { + headers: { cookie: 'better-auth.session_token=existing.session' }, + }) + ) + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe('https://sim.test/home') + } + }) + + it('uses the same redirect precedence as the form and requires authentication for OAuth', async () => { + const destination = new URL('/login', 'https://sim.test') + destination.searchParams.set('callbackUrl', '/api/auth/oauth2/authorize?client_id=sim-cli') + destination.searchParams.set('redirect', '/workspace') + const headers = { cookie: 'better-auth.session_token=existing.session' } + expect((await proxy(new NextRequest(destination, { headers }))).status).toBe(307) + + destination.searchParams.delete('redirect') + flags.authDisabled = true + expect((await proxy(new NextRequest(destination, { headers }))).status).toBe(307) + }) +}) diff --git a/apps/sim/app/(auth)/oauth/sign-in/route.ts b/apps/sim/app/(auth)/oauth/sign-in/route.ts new file mode 100644 index 00000000000..ff4b8b582ef --- /dev/null +++ b/apps/sim/app/(auth)/oauth/sign-in/route.ts @@ -0,0 +1,61 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { isAuthDisabled, isRegistrationDisabled } from '@/lib/core/config/env-flags' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect' + +/** + * Query parameters the OAuth plugin adds when it signs the authorize query for + * a `loginPage` redirect. Re-entering authorize starts a fresh request, which + * signs its own, so carrying the old ones forward would only widen the URL. + */ +const SIGNED_QUERY_PARAMS = new Set(['sig', 'exp', 'ba_iat', 'ba_param', 'ba_pl']) + +/** Removes prompts completed by the login/signup hop while preserving later consent prompts. */ +function consumeInteractivePrompt(params: URLSearchParams): boolean { + const prompts = (params.get('prompt') ?? '').split(/\s+/).filter(Boolean) + const requiresLogin = prompts.includes('login') + const remaining = prompts.filter((prompt) => prompt !== 'login' && prompt !== 'create') + if (remaining.length > 0) params.set('prompt', remaining.join(' ')) + else params.delete('prompt') + return requiresLogin +} + +/** + * The OAuth provider's `loginPage`: where a signed-out user lands when a client + * starts `/api/auth/oauth2/authorize`. + * + * The plugin forwards the whole authorize query, signed. The authorize endpoint + * is stateless, so the flow resumes by simply visiting it again once a session + * exists. This route rebuilds that URL from the original parameters and hands + * it to the normal auth pages as `callbackUrl`, which is how every other + * post-login destination in Sim travels — no second login form, no plugin + * client hooks to keep in step. + * + * Signup rather than login by default, for the same reason the CLI handoff + * chooses it: this is reached from a terminal, often on a fresh install. Under + * DISABLE_REGISTRATION nobody can create an account, so login is the only hop + * that can succeed. + */ +export const GET = withRouteHandler(async (request: NextRequest) => { + /** Avoid sending a newly signed-in user to a disabled provider's JSON 404. */ + if (isAuthDisabled) { + return NextResponse.redirect(new URL('/', getBaseUrl()), 302) + } + + const params = new URLSearchParams(request.nextUrl.search) + for (const name of SIGNED_QUERY_PARAMS) params.delete(name) + const requiresLogin = consumeInteractivePrompt(params) + + const authorizeUrl = `/api/auth/oauth2/authorize?${params.toString()}` + const destination = buildAuthCrossLink( + isRegistrationDisabled || requiresLogin ? '/login' : '/signup', + { + callbackUrl: authorizeUrl, + isInviteFlow: false, + } + ) + + /** Use the auth server's origin: Next normalizes loopback hosts and proxy ingress may differ. */ + return NextResponse.redirect(new URL(destination, getBaseUrl()), 302) +}) diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx index 61ad48a8328..7488520915a 100644 --- a/apps/sim/app/(auth)/signup/signup-form.tsx +++ b/apps/sim/app/(auth)/signup/signup-form.tsx @@ -408,7 +408,9 @@ function SignupFormContent({
- {hasOnlySSO && } + {hasOnlySSO && ( + + )} {emailEnabled && (
@@ -486,10 +488,13 @@ function SignupFormContent({ githubAvailable={githubAvailable} googleAvailable={googleAvailable} microsoftAvailable={microsoftAvailable} - callbackURL={redirectUrl || '/workspace'} + callbackURL={redirectUrl || DEFAULT_POST_AUTH_ROUTE} > {ssoEnabled && !hasOnlySSO && ( - + )} )} diff --git a/apps/sim/app/(auth)/verify/use-verification.ts b/apps/sim/app/(auth)/verify/use-verification.ts index 96bd55f1f88..c2fbe5d4dbb 100644 --- a/apps/sim/app/(auth)/verify/use-verification.ts +++ b/apps/sim/app/(auth)/verify/use-verification.ts @@ -6,7 +6,7 @@ import { normalizeEmail } from '@sim/utils/string' import { useRouter, useSearchParams } from 'next/navigation' import { client, useSession } from '@/lib/auth/auth-client' import { validateCallbackUrl } from '@/lib/core/security/input-validation' -import { POST_AUTH_REDIRECT_STORAGE_KEY } from '@/app/(auth)/auth-redirect' +import { DEFAULT_POST_AUTH_ROUTE, POST_AUTH_REDIRECT_STORAGE_KEY } from '@/app/(auth)/auth-redirect' const logger = createLogger('useVerification') @@ -16,7 +16,7 @@ const logger = createLogger('useVerification') * * Both redirect sites run in the same commit as the effect that reads session * storage, so a cached value is still `null` when they fire and the stored - * destination is silently replaced by `/workspace`. Reading here removes that + * destination is silently replaced by the default entry. Reading here removes that * race. `redirectAfter` wins over the stored URL; anything failing callback * validation is discarded, and an unsafe stored value is evicted. */ @@ -112,7 +112,8 @@ export function useVerification({ logger.warn('Failed to refetch session after verification', e) } - const destination = resolveRedirectUrl(searchParams.get('redirectAfter')) ?? '/workspace' + const destination = + resolveRedirectUrl(searchParams.get('redirectAfter')) ?? DEFAULT_POST_AUTH_ROUTE sessionStorage.removeItem('verificationEmail') sessionStorage.removeItem(POST_AUTH_REDIRECT_STORAGE_KEY) @@ -217,7 +218,7 @@ export function useVerification({ if (destination) { window.location.href = destination } else { - router.push('/workspace') + router.push(DEFAULT_POST_AUTH_ROUTE) } } diff --git a/apps/sim/app/(interfaces)/chat/components/error-state/error-state.tsx b/apps/sim/app/(interfaces)/chat/components/error-state/error-state.tsx index 8d14e3a1ee9..31aa3527fb9 100644 --- a/apps/sim/app/(interfaces)/chat/components/error-state/error-state.tsx +++ b/apps/sim/app/(interfaces)/chat/components/error-state/error-state.tsx @@ -2,6 +2,7 @@ import { Button } from '@sim/emcn' import { useRouter } from 'next/navigation' +import { APP_ENTRY_PATH } from '@/lib/navigation/paths' interface ChatErrorStateProps { error: string @@ -19,10 +20,10 @@ export function ChatErrorState({ error }: ChatErrorStateProps) {

{error}

diff --git a/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx b/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx index 6e182a8ef48..a279ec39f27 100644 --- a/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx +++ b/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx @@ -21,6 +21,7 @@ import { type AuthProviderStatusResponse, getAuthProvidersContract } from '@/lib import { client } from '@/lib/auth/auth-client' import { getEnv, isFalsy } from '@/lib/core/config/env' import { isSsoEnabled } from '@/lib/core/config/env-flags' +import { APP_ENTRY_PATH } from '@/lib/navigation/paths' import { captureClientEvent } from '@/lib/posthog/client' import type { PostHogEventMap } from '@/lib/posthog/events' import colorMixFallbacks from '@/app/(landing)/components/shared/color-mix-fallbacks/color-mix-fallbacks.module.css' @@ -143,7 +144,7 @@ export function AuthModal({ children, defaultView = 'login', source }: AuthModal async function handleSocialLogin(provider: 'github' | 'google' | 'microsoft') { setSocialLoading(provider) try { - await client.signIn.social({ provider, callbackURL: '/workspace' }) + await client.signIn.social({ provider, callbackURL: APP_ENTRY_PATH }) } catch (error) { logger.warn('Social sign-in did not complete', { provider, error }) } finally { diff --git a/apps/sim/app/(landing)/privacy/privacy-content.tsx b/apps/sim/app/(landing)/privacy/privacy-content.tsx index ca6d93d4e9c..ffc57df52e2 100644 --- a/apps/sim/app/(landing)/privacy/privacy-content.tsx +++ b/apps/sim/app/(landing)/privacy/privacy-content.tsx @@ -264,7 +264,7 @@ export const PRIVACY_CONFIG: LegalPageConfig = { 'Behavioral remarketing', 'Cookie and pixel identifiers, browser and Device data, campaign attribution, and website interaction data', 'Consent — Article 6(1)(a)', - 'Marketing technologies are disabled until the Marketing category is accepted. Consent may be changed or withdrawn through the cookie preferences link.', + 'Marketing cookies and ad personalization require Marketing consent. Google Ads may load earlier with ad storage denied and send limited cookieless consent signals. Consent may be changed or withdrawn through the cookie preferences link.', ], [ 'Retaining transaction and tax records', @@ -576,7 +576,7 @@ export const PRIVACY_CONFIG: LegalPageConfig = { { kind: 'paragraph', content: richText( - 'The Company uses Google Ads, Twitter, and Facebook remarketing services to advertise on third-party websites after You visit the Service. These services operate through non-essential Cookies and similar technologies. They are activated only after You give consent to the Marketing category in the cookie banner. No marketing Cookie is set before that consent.' + 'The Company uses Google Ads, Twitter, and Facebook remarketing services to advertise on third-party websites after You visit the Service. These services operate through non-essential Cookies and similar technologies. Marketing Cookies and ad personalization are enabled only after You give consent to the Marketing category in the cookie banner. Google Ads may load before that consent with ad storage denied and send limited cookieless consent signals, as described in the Cookie Policy.' ), }, { diff --git a/apps/sim/app/.well-known/oauth-authorization-server/api/auth/route.ts b/apps/sim/app/.well-known/oauth-authorization-server/api/auth/route.ts new file mode 100644 index 00000000000..2a91de32010 --- /dev/null +++ b/apps/sim/app/.well-known/oauth-authorization-server/api/auth/route.ts @@ -0,0 +1,5 @@ +import { getOAuthProviderMetadataResponse } from '@/lib/auth/oauth-provider-metadata' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +/** RFC 8414 metadata location derived from Sim's `/api/auth` issuer path. */ +export const GET = withRouteHandler(getOAuthProviderMetadataResponse) diff --git a/apps/sim/app/.well-known/oauth-authorization-server/route.ts b/apps/sim/app/.well-known/oauth-authorization-server/route.ts new file mode 100644 index 00000000000..55f7c4928bc --- /dev/null +++ b/apps/sim/app/.well-known/oauth-authorization-server/route.ts @@ -0,0 +1,18 @@ +import { getOAuthProviderMetadataResponse } from '@/lib/auth/oauth-provider-metadata' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +/** + * RFC 8414 authorization-server metadata at the origin root. The plugin serves + * the same document under `/api/auth/.well-known/`; this route exists so a + * client that only knows Sim's origin can discover the endpoints, and so the + * Sim CLI can probe one URL to learn whether the provider is on before + * choosing a login flow. Deliberately 404 rather than an empty document when + * it is off: "no authorization server here" is the answer. + * + * Note the issuer this document names is `/api/auth`, which is where + * Better Auth mounts the provider — so a client following RFC 8414 §3.1 to the + * letter would look under `/.well-known/oauth-authorization-server/api/auth`. + * This copy is the probe; Sim serves the issuer-derived alias from the same + * response helper so every discovery path stays byte-for-byte equivalent. + */ +export const GET = withRouteHandler(getOAuthProviderMetadataResponse) diff --git a/apps/sim/app/_shell/consent/consent-banner.tsx b/apps/sim/app/_shell/consent/consent-banner.tsx index bd9d4bfdb7b..a3878c0d02b 100644 --- a/apps/sim/app/_shell/consent/consent-banner.tsx +++ b/apps/sim/app/_shell/consent/consent-banner.tsx @@ -54,7 +54,9 @@ export function ConsentBanner() {

Cookies

- We use cookies to run Sim, understand how it is used, and improve it. Read our{' '} + Necessary cookies keep Sim working. Optional cookies help us understand usage, measure + campaigns, and personalize ads on other sites. You can change or withdraw consent at + any time in Privacy settings or through our{' '} Cookie Policy diff --git a/apps/sim/app/_shell/consent/consent-preferences.tsx b/apps/sim/app/_shell/consent/consent-preferences.tsx index faf620b1767..7bccb6fa860 100644 --- a/apps/sim/app/_shell/consent/consent-preferences.tsx +++ b/apps/sim/app/_shell/consent/consent-preferences.tsx @@ -38,7 +38,7 @@ const CONSENT_CATEGORY_COPY: Record = { }, marketing: { title: 'Marketing', - description: 'Measures which campaigns bring builders to Sim.', + description: 'Measures campaigns and personalizes ads on other sites.', }, } satisfies Record diff --git a/apps/sim/app/_shell/consent/consent-store-provider.test.tsx b/apps/sim/app/_shell/consent/consent-store-provider.test.tsx index 1c80f3b63d5..2c59306355f 100644 --- a/apps/sim/app/_shell/consent/consent-store-provider.test.tsx +++ b/apps/sim/app/_shell/consent/consent-store-provider.test.tsx @@ -52,6 +52,7 @@ describe('ConsentStoreProvider', () => { expect.objectContaining({ id: 'ahrefs-analytics', category: 'measurement' }), ], store: { + storageConfig: { defaultExpiryDays: 365 }, reloadOnConsentRevoked: true, iframeBlockerConfig: { disableAutomaticBlocking: true }, }, diff --git a/apps/sim/app/_shell/consent/consent-store-provider.tsx b/apps/sim/app/_shell/consent/consent-store-provider.tsx index 79403e3022e..b37b460daef 100644 --- a/apps/sim/app/_shell/consent/consent-store-provider.tsx +++ b/apps/sim/app/_shell/consent/consent-store-provider.tsx @@ -8,6 +8,7 @@ import { DEV_CONSENT_COUNTRY, } from '@/lib/consent/constants' import { GLOBAL_CONSENT_SCRIPTS } from '@/lib/consent/scripts' +import { CONSENT_STORAGE_CONFIG } from '@/lib/consent/storage' /** * Imported from `@c15t/nextjs/headless`, not the package root: the headless @@ -29,6 +30,7 @@ const CONSENT_OPTIONS = { consentCategories: [...CONSENT_CATEGORIES], scripts: [...GLOBAL_CONSENT_SCRIPTS], store: { + storageConfig: CONSENT_STORAGE_CONFIG, reloadOnConsentRevoked: true, iframeBlockerConfig: { disableAutomaticBlocking: true }, }, diff --git a/apps/sim/app/_shell/desktop-title-bar-surfaces.test.ts b/apps/sim/app/_shell/desktop-title-bar-surfaces.test.ts index 2584ea88c37..8e13703055c 100644 --- a/apps/sim/app/_shell/desktop-title-bar-surfaces.test.ts +++ b/apps/sim/app/_shell/desktop-title-bar-surfaces.test.ts @@ -151,12 +151,14 @@ describe('desktop title-bar surface audit', () => { expect(rule).not.toContain('margin-top') }) - it('drops the content pane border where the pane meets the window edge', () => { - // Collapsing the sidebar in the desktop shell takes the pane's padding to 0, so a - // retained border and radius drew a hairline outline inset from the square window. + it('drops the pane divider where the pane meets the window edge', () => { + // The pane meets the rail on a single left hairline. Collapsing the sidebar in the + // desktop shell leaves no rail beside it, so a retained divider would draw a stray + // line down the window's left edge. The pane carries no radius or full border to + // drop anymore; the divider is the only chrome between them. const flush = '[[data-sim-desktop-title-bar=inset]_[data-sidebar-collapsed]_&]:' - expect(workspaceChrome).toContain(`${flush}rounded-none`) - expect(workspaceChrome).toContain(`${flush}border-0`) + expect(workspaceChrome).toContain(`${flush}border-l-0`) + expect(workspaceChrome).not.toContain('rounded-[8px]') }) it('clears the lane for panels that embed pages away from the lights', () => { @@ -289,6 +291,9 @@ const SELF_RESERVE_REQUIRED = new Set([ // `WorkspaceHostProvider` — an ancestor of the chrome, not a descendant — returns it // instead of its children on a client-side 403. Neither is a double reservation. 'app/workspace/[workspaceId]/components/workspace-access-denied.tsx', + // Same shape on the organization surface: `o/[organizationId]/layout.tsx` returns it + // for a non-member before reaching ``. + 'app/o/[organizationId]/components/organization-access-denied.tsx', ]) /** Every file under `app/`, so ancestor layouts can be resolved without extra fs calls. */ diff --git a/apps/sim/app/_shell/providers/browser-telemetry.test.tsx b/apps/sim/app/_shell/providers/browser-telemetry.test.tsx new file mode 100644 index 00000000000..83729ca12c7 --- /dev/null +++ b/apps/sim/app/_shell/providers/browser-telemetry.test.tsx @@ -0,0 +1,153 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { session, consent, settings, start, stop } = vi.hoisted(() => { + const stop = vi.fn() + return { + session: { + data: null as { user: { id: string } } | null, + isPending: true, + error: null as Error | null, + }, + consent: { isResolved: false, measurement: false }, + settings: { data: undefined as { telemetryEnabled: boolean } | undefined, isError: false }, + start: vi.fn(() => stop), + stop, + } +}) + +vi.mock('@/lib/auth/auth-client', () => ({ useSession: () => session })) +vi.mock('@/lib/consent/tracking-consent', () => ({ useTrackingConsent: () => consent })) +vi.mock('@/hooks/queries/general-settings', () => ({ useGeneralSettings: () => settings })) +vi.mock('@/lib/telemetry/browser', () => ({ startBrowserTelemetry: start })) + +import { setBrowserTelemetryPreference } from '@/lib/telemetry/browser-preference' +import { BrowserTelemetry } from '@/app/_shell/providers/browser-telemetry' + +let root: Root +let container: HTMLDivElement + +function render(disabled = false, consentRequired = true) { + act(() => root.render()) +} + +beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + root = createRoot(container) + localStorage.clear() +}) + +afterEach(() => { + act(() => root.unmount()) + session.data = null + session.isPending = true + session.error = null + consent.isResolved = false + consent.measurement = false + settings.data = undefined + settings.isError = false + setBrowserTelemetryPreference(true) + localStorage.clear() + vi.clearAllMocks() +}) + +describe('BrowserTelemetry permission boundary', () => { + it('waits for consent and saved account settings, then stops on either withdrawal', () => { + render() + session.isPending = false + session.data = { user: { id: 'user-1' } } + render() + consent.isResolved = true + consent.measurement = true + render() + expect(start).not.toHaveBeenCalled() + + settings.data = { telemetryEnabled: false } + render() + expect(start).not.toHaveBeenCalled() + settings.data.telemetryEnabled = true + render() + expect(start).toHaveBeenCalledTimes(1) + settings.data.telemetryEnabled = false + render() + expect(stop).toHaveBeenCalledTimes(1) + settings.data.telemetryEnabled = true + render() + consent.measurement = false + render() + expect(stop).toHaveBeenCalledTimes(2) + }) + + it('honors the deployment disable switch and does not require hosted consent when self-hosted', () => { + session.isPending = false + render(true, false) + expect(start).not.toHaveBeenCalled() + render(false, false) + expect(start).toHaveBeenCalledTimes(1) + render(true, false) + expect(stop).toHaveBeenCalledTimes(1) + }) + + it('keeps a saved browser refusal effective and observes local changes', () => { + session.isPending = false + consent.isResolved = true + consent.measurement = true + setBrowserTelemetryPreference(false) + render() + expect(start).not.toHaveBeenCalled() + act(() => setBrowserTelemetryPreference(true)) + expect(start).toHaveBeenCalledTimes(1) + act(() => setBrowserTelemetryPreference(false)) + expect(stop).toHaveBeenCalledTimes(1) + }) + + it('stops on session or settings errors even with previously allowed data', () => { + session.isPending = false + session.data = { user: { id: 'user-1' } } + consent.isResolved = true + consent.measurement = true + settings.data = { telemetryEnabled: true } + render() + settings.isError = true + render() + expect(stop).toHaveBeenCalledTimes(1) + settings.isError = false + session.error = new Error('Session unavailable') + render() + expect(start).toHaveBeenCalledTimes(1) + }) + + it('keeps newer Analytics withdrawals effective across restarts without reacting to Marketing', () => { + session.isPending = false + consent.isResolved = true + consent.measurement = true + render() + const save = (measurement: boolean, marketing: boolean) => { + const newValue = JSON.stringify({ consents: { measurement, marketing } }) + act(() => { + localStorage.setItem('c15t', newValue) + window.dispatchEvent(new StorageEvent('storage', { key: 'c15t', newValue })) + }) + } + save(true, false) + expect(stop).not.toHaveBeenCalled() + save(false, false) + expect(stop).toHaveBeenCalledTimes(1) + act(() => setBrowserTelemetryPreference(false)) + act(() => setBrowserTelemetryPreference(true)) + expect(start).toHaveBeenCalledTimes(1) + act(() => { + localStorage.clear() + window.dispatchEvent(new StorageEvent('storage', { key: null })) + }) + render() + expect(start).toHaveBeenCalledTimes(1) + save(true, false) + expect(start).toHaveBeenCalledTimes(2) + }) +}) diff --git a/apps/sim/app/_shell/providers/browser-telemetry.tsx b/apps/sim/app/_shell/providers/browser-telemetry.tsx new file mode 100644 index 00000000000..3afebd66b0a --- /dev/null +++ b/apps/sim/app/_shell/providers/browser-telemetry.tsx @@ -0,0 +1,82 @@ +'use client' + +import { useEffect, useSyncExternalStore } from 'react' +import { useSession } from '@/lib/auth/auth-client' +import { + getStoredMeasurementPermission, + subscribeStoredMeasurementPermission, +} from '@/lib/consent/measurement-permission' +import { useTrackingConsent } from '@/lib/consent/tracking-consent' +import { startBrowserTelemetry } from '@/lib/telemetry/browser' +import { + getBrowserTelemetryPreference, + subscribeBrowserTelemetryPreference, +} from '@/lib/telemetry/browser-preference' +import { useGeneralSettings } from '@/hooks/queries/general-settings' + +interface BrowserTelemetryProps { + disabled: boolean + consentRequired: boolean +} + +interface TelemetryCaptureProps { + enabled: boolean + consentRequired: boolean +} + +interface AccountTelemetryProps { + consentRequired: boolean +} + +function getServerPreference(): boolean { + return false +} + +function TelemetryCapture({ enabled, consentRequired }: TelemetryCaptureProps) { + const browserAllowsTelemetry = useSyncExternalStore( + subscribeBrowserTelemetryPreference, + getBrowserTelemetryPreference, + getServerPreference + ) + + useEffect(() => { + if (enabled && browserAllowsTelemetry) return startBrowserTelemetry(consentRequired) + }, [enabled, browserAllowsTelemetry, consentRequired]) + + return null +} + +function AccountTelemetry({ consentRequired }: AccountTelemetryProps) { + const { data, isError } = useGeneralSettings() + return ( + + ) +} + +/** Collects only after session, account preferences, and applicable cookie consent have resolved. */ +export function BrowserTelemetry({ disabled, consentRequired }: BrowserTelemetryProps) { + const { data, isPending, error } = useSession() + const { isResolved, measurement } = useTrackingConsent() + const storedPermission = useSyncExternalStore( + subscribeStoredMeasurementPermission, + getStoredMeasurementPermission, + getServerPreference + ) + if ( + disabled || + isPending || + error || + (consentRequired && (!isResolved || !measurement || !storedPermission)) + ) { + return null + } + + return data?.user ? ( + + ) : ( + + ) +} diff --git a/apps/sim/app/_styles/globals.css b/apps/sim/app/_styles/globals.css index 232ab9ebe9b..c99b6a04651 100644 --- a/apps/sim/app/_styles/globals.css +++ b/apps/sim/app/_styles/globals.css @@ -392,7 +392,7 @@ :root { --sidebar-width: 0px; /* 0 outside workspace; blocking script always sets actual value on workspace pages */ --sidebar-collapsed-width: 48px; /* icon rail on web; desktop overrides to 0 before first paint */ - --sidebar-expanded-width: 238px; /* SIDEBAR_WIDTH.DEFAULT; the width to restore to, held even while collapsed */ + --sidebar-expanded-width: 256px; /* SIDEBAR_WIDTH.DEFAULT; the width to restore to, held even while collapsed */ --desktop-title-bar-height: 0px; /* macOS traffic-light lane; desktop overrides before first paint */ --workspace-content-title-bar-inset: 0px; /* lane the content pane must leave clear; only non-zero when the pane, not the sidebar, sits under it */ --desktop-title-bar-inset-x: 0px; /* clearance past the traffic lights; desktop overrides */ @@ -403,17 +403,12 @@ --editor-connections-height: 172px; /* EDITOR_CONNECTIONS_HEIGHT.DEFAULT */ --terminal-height: 206px; /* TERMINAL_HEIGHT.DEFAULT */ /** - * The padding `.workspace-content-shell` insets the panel and terminal from - * the viewport by (CONTENT_WINDOW_GAP). - * - * Published here because surfaces portalled to `` — the toast stack — - * position against those elements from the viewport, so they must add back - * whatever separates the element from the viewport edge. Reading it rather - * than hardcoding 8px is what keeps the toast and the canvas controls on the - * same clearance when the shell drops its padding; the controls are laid out - * inside the shell and so need no correction. + * Distance between `.workspace-content-shell` and the viewport edge + * (CONTENT_WINDOW_GAP). The shell sits flush, so this is zero; it stays + * published because surfaces portalled to `` — the toast stack — and + * the panel and terminal geometry all read it rather than assuming a value. */ - --workspace-content-gap: 8px; + --workspace-content-gap: 0px; --output-panel-width: 560px; /* OUTPUT_PANEL_WIDTH.DEFAULT */ /** * Neutral border and divider thickness. Standard-density displays cannot draw @@ -562,14 +557,6 @@ html[data-sim-desktop-title-bar="inset"] --workspace-content-title-bar-inset: var(--desktop-title-bar-height); } -/* The one case the shell drops its padding entirely (see `workspace-chrome.tsx`: - `isCollapsed && '[[data-sim-desktop-title-bar=inset]_&]:p-0'`). Declared on the - root so the portalled toast stack — which cannot inherit from the shell — sees - it too, and keeps the same clearance the in-shell canvas controls keep. */ -html[data-sim-desktop-title-bar="inset"]:has(.workspace-content-shell[data-sidebar-collapsed]) { - --workspace-content-gap: 0px; -} - .workspace-root code, .workspace-root kbd, .workspace-root samp, @@ -580,7 +567,6 @@ html[data-sim-desktop-title-bar="inset"]:has(.workspace-content-shell[data-sideb .sidebar-container { width: var(--sidebar-width); - transition: width 200ms cubic-bezier(0.25, 0.1, 0.25, 1); } /** @@ -607,12 +593,6 @@ html[data-sim-desktop-title-bar="inset"]:has(.workspace-content-shell[data-sideb --sidebar-width: var(--sidebar-expanded-width); } -/* The card appears at full width, so the aside's own width transition would animate - 0 -> expanded inside it. */ -.sidebar-shell-outer[data-peek] .sidebar-container { - transition: none; -} - /* The card is a flex column sized to its content, so the shell must be allowed to shrink for the sidebar's own scroll region to bound itself once the card hits its max height. Docked, this element is not a flex item and the rule is inert. */ @@ -623,7 +603,6 @@ html[data-sim-desktop-title-bar="inset"]:has(.workspace-content-shell[data-sideb .sidebar-container span, .sidebar-container .text-small { - transition: opacity 120ms ease; white-space: nowrap; } @@ -632,51 +611,10 @@ html[data-sim-desktop-title-bar="inset"]:has(.workspace-content-shell[data-sideb opacity: 0; } -.sidebar-container .sidebar-collapse-hide { - transition: opacity 60ms ease; -} - .sidebar-container[data-collapsed] .sidebar-collapse-hide { opacity: 0; } -@keyframes sidebar-collapse-guard { - from { - pointer-events: none; - } - to { - pointer-events: auto; - } -} - -.sidebar-container[data-collapsed] { - animation: sidebar-collapse-guard 250ms step-end; -} - -.sidebar-container.is-resizing { - transition: none; -} - -/* Suppress width/transform transitions on the chrome wrappers during a - drag-resize so the outer overflow-hidden clip doesn't lag behind the inner - sidebar content, which is already at the correct width instantly. */ -html.sidebar-resizing .sidebar-shell-outer, -html.sidebar-resizing .sidebar-shell-inner { - transition: none !important; -} - -/* Suppress sidebar transitions during the initial hydration window. The - pre-paint script sets the correct --sidebar-width, but store rehydration - re-applies it a tick later; without this guard that re-apply animates the - rail, reading as a collapse -> expand flash on a fresh page load. Removed - after the first paint (see workspace-chrome.tsx) so user-driven toggles and - the fullscreen slide still animate. */ -html.sidebar-booting .sidebar-container, -html.sidebar-booting .sidebar-shell-outer, -html.sidebar-booting .sidebar-shell-inner { - transition: none !important; -} - .panel-container { width: var(--panel-width); } @@ -787,6 +725,9 @@ html.sidebar-booting .sidebar-shell-inner { --brand-secondary: #33b4ff; --brand-accent: #33c482; --brand-accent-hover: #2dac72; + /* Progress and completion — the checked step, the done state. Deeper and + quieter than --selection, which stays the interactive highlight. */ + --brand-blue: #3b6fe0; --selection: #1a5cf6; --selection-muted: #1a5cf647; --warning: #ea580c; @@ -948,6 +889,8 @@ html.sidebar-booting .sidebar-shell-inner { --brand-secondary: #33b4ff; --brand-accent: #33c482; --brand-accent-hover: #2dac72; + /* Lifted for contrast on dark surfaces, the same step --selection takes. */ + --brand-blue: #5b8def; --selection: #4b83f7; --selection-muted: #4b83f759; --warning: #ff6600; diff --git a/apps/sim/app/account/settings/[section]/page.test.tsx b/apps/sim/app/account/settings/[section]/page.test.tsx new file mode 100644 index 00000000000..838297805e3 --- /dev/null +++ b/apps/sim/app/account/settings/[section]/page.test.tsx @@ -0,0 +1,58 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetSession, mockPrefetch } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockPrefetch: vi.fn(), +})) + +vi.mock('next/navigation', () => ({ + notFound: () => { + throw new Error('NEXT_NOT_FOUND') + }, + redirect: (href: string) => { + throw new Error(`NEXT_REDIRECT:${href}`) + }, +})) +vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) +vi.mock('@/lib/core/config/env-flags', () => ({ isBillingEnabled: true })) +vi.mock('@/lib/permissions/super-user', () => ({ isPlatformAdmin: vi.fn() })) +vi.mock('@/app/_shell/providers/get-query-client', () => ({ getQueryClient: vi.fn() })) +vi.mock('@/components/settings/prefetch-standalone-general', () => ({ + prefetchStandaloneGeneral: mockPrefetch, +})) +vi.mock('@/components/settings/account-settings-renderer', () => ({ + AccountSettingsRenderer: () => null, +})) + +import AccountSettingsSectionPage from '@/app/account/settings/[section]/page' + +const pageProps = (section: string) => ({ params: Promise.resolve({ section }) }) + +describe('account settings legacy links', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ user: { id: 'viewer-a' } }) + }) + + it('redirects Authorized apps bookmarks to the General subview', async () => { + await expect(AccountSettingsSectionPage(pageProps('authorized-apps'))).rejects.toThrow( + 'NEXT_REDIRECT:/account/settings/general?view=authorized-apps' + ) + expect(mockPrefetch).not.toHaveBeenCalled() + }) + + it('authenticates before following the legacy bookmark', async () => { + mockGetSession.mockResolvedValue(null) + + await expect(AccountSettingsSectionPage(pageProps('authorized-apps'))).rejects.toThrow( + 'NEXT_REDIRECT:/login' + ) + }) + + it('still rejects unknown sections', async () => { + await expect(AccountSettingsSectionPage(pageProps('unknown'))).rejects.toThrow('NEXT_NOT_FOUND') + }) +}) diff --git a/apps/sim/app/account/settings/[section]/page.tsx b/apps/sim/app/account/settings/[section]/page.tsx index 424a05612cd..a4a551f1a89 100644 --- a/apps/sim/app/account/settings/[section]/page.tsx +++ b/apps/sim/app/account/settings/[section]/page.tsx @@ -41,6 +41,9 @@ export default async function AccountSettingsSectionPage({ if (!session?.user) redirect('/login') const { section } = await params + if (section === 'authorized-apps') { + redirect(`${getAccountSettingsHref('general')}?view=authorized-apps`) + } const parsed = parseSettingsPathSection({ path: section, items: ACCOUNT_SETTINGS_ITEMS, diff --git a/apps/sim/app/api/auth/.well-known/oauth-authorization-server/route.ts b/apps/sim/app/api/auth/.well-known/oauth-authorization-server/route.ts new file mode 100644 index 00000000000..94ac2445646 --- /dev/null +++ b/apps/sim/app/api/auth/.well-known/oauth-authorization-server/route.ts @@ -0,0 +1,5 @@ +import { getOAuthProviderMetadataResponse } from '@/lib/auth/oauth-provider-metadata' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +/** Better Auth's issuer-prefixed RFC 8414 compatibility location. */ +export const GET = withRouteHandler(getOAuthProviderMetadataResponse) diff --git a/apps/sim/app/api/auth/[...all]/route.test.ts b/apps/sim/app/api/auth/[...all]/route.test.ts index e173e2ffee0..6257b7f4519 100644 --- a/apps/sim/app/api/auth/[...all]/route.test.ts +++ b/apps/sim/app/api/auth/[...all]/route.test.ts @@ -63,6 +63,7 @@ vi.mock('@/app/api/credential-groups/oauth-callback', () => ({ import { GET, POST } from '@/app/api/auth/[...all]/route' afterAll(resetEnvFlagsMock) +beforeEach(() => setEnvFlags({ isAuthDisabled: false })) describe('auth catch-all route managed OAuth callbacks', () => { beforeEach(() => { @@ -97,21 +98,25 @@ describe('auth catch-all route managed OAuth callbacks', () => { expect(handlerMocks.betterAuthGET).not.toHaveBeenCalled() }) - it('leaves ordinary connector callbacks with Better Auth', async () => { - handlerMocks.betterAuthGET.mockResolvedValueOnce(new Response(null, { status: 204 })) - const request = createMockRequest( - 'GET', - undefined, - {}, - 'http://localhost:3000/api/auth/oauth2/callback/jira?state=better-auth-state&code=code-1' - ) - - const response = await GET(request) - - expect(response.status).toBe(204) - expect(handlerMocks.betterAuthGET).toHaveBeenCalledWith(request) - expect(handlerMocks.credentialGroupCallback).not.toHaveBeenCalled() - }) + it.each([true, false])( + 'preserves connector callbacks with authentication disabled=%s', + async (authDisabled) => { + setEnvFlags({ isAuthDisabled: authDisabled }) + handlerMocks.betterAuthGET.mockResolvedValueOnce(new Response(null, { status: 204 })) + const request = createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/auth/oauth2/callback/jira?state=better-auth-state&code=code-1' + ) + + const response = await GET(request) + + expect(response.status).toBe(204) + expect(handlerMocks.betterAuthGET).toHaveBeenCalledWith(request) + expect(handlerMocks.credentialGroupCallback).not.toHaveBeenCalled() + } + ) it('rejects a managed state sent to an unsupported connector callback', async () => { const request = createMockRequest( @@ -297,3 +302,112 @@ describe('auth catch-all route SSO provider mutations', () => { expect(await res.json()).toEqual({ data: { url: 'https://idp.example.com' } }) }) }) + +describe('OAuth provider client endpoints', () => { + beforeEach(() => { + vi.clearAllMocks() + handlerMocks.betterAuthPOST.mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { status: 200 }) + ) + }) + + it.each(['.well-known/openid-configuration', 'oauth2/end-session', 'oauth2/userinfo'])( + 'does not expose the OIDC-only %s endpoint', + async (path) => { + const getResponse = await GET( + createMockRequest('GET', undefined, {}, `http://localhost:3000/api/auth/${path}`) + ) + const postResponse = await POST( + createMockRequest('POST', {}, {}, `http://localhost:3000/api/auth/${path}`) + ) + + expect(getResponse.status).toBe(404) + expect(postResponse.status).toBe(404) + expect(getResponse.headers.get('cache-control')).toBe('no-store') + expect(handlerMocks.betterAuthGET).not.toHaveBeenCalled() + expect(handlerMocks.betterAuthPOST).not.toHaveBeenCalled() + } + ) + + /** + * The plugin gates client creation on a session alone, so without this any + * signed-in user could register a client with arbitrary redirect URIs and + * the full scope set. Nothing must reach the plugin. + */ + it.each([ + 'oauth2/create-client', + 'oauth2/update-client', + 'oauth2/delete-client', + 'oauth2/client/rotate-secret', + 'oauth2/register', + 'oauth2/introspect', + 'oauth2/token', + 'oauth2/revoke', + 'oauth2/anything-a-future-version-adds', + ])('refuses POST /%s without reaching Better Auth', async (path) => { + const req = createMockRequest('POST', {}, {}, `http://localhost:3000/api/auth/${path}`) + + const res = await POST(req) + + expect(res.status).toBe(404) + expect(handlerMocks.betterAuthPOST).not.toHaveBeenCalled() + }) + + it.each([ + 'oauth2/consent', + 'oauth2/continue', + 'oauth2/public-client-prelogin', + 'oauth2/callback/jira', + ])('lets the protocol endpoint %s through', async (path) => { + const req = createMockRequest('POST', {}, {}, `http://localhost:3000/api/auth/${path}`) + + await POST(req) + + expect(handlerMocks.betterAuthPOST).toHaveBeenCalledTimes(1) + }) + + it.each(['oauth2/consent', 'oauth2/continue', 'oauth2/public-client-prelogin'])( + 'requires authentication for %s', + async (path) => { + setEnvFlags({ isAuthDisabled: true }) + const request = createMockRequest('POST', {}, {}, `http://localhost:3000/api/auth/${path}`) + const response = await POST(request) + expect(response.status).toBe(404) + expect(response.headers.get('cache-control')).toBe('no-store') + expect(handlerMocks.betterAuthPOST).not.toHaveBeenCalled() + } + ) + + it.each([true, false])( + 'preserves connector POST callbacks with authentication disabled=%s', + async (authDisabled) => { + setEnvFlags({ isAuthDisabled: authDisabled }) + const request = createMockRequest( + 'POST', + {}, + {}, + 'http://localhost:3000/api/auth/oauth2/callback/jira' + ) + expect((await POST(request)).status).toBe(200) + expect(handlerMocks.betterAuthPOST).toHaveBeenCalledExactlyOnceWith(request) + } + ) + + it.each([true, false])( + 'preserves authenticated connector linking with authentication disabled=%s', + async (authDisabled) => { + setEnvFlags({ isAuthDisabled: authDisabled }) + const request = createMockRequest( + 'POST', + { providerId: 'google-email', callbackURL: 'http://localhost:3000/workspace' }, + { cookie: 'better-auth.session_token=existing-session' }, + 'http://localhost:3000/api/auth/oauth2/link' + ) + + const response = await POST(request) + + expect(response.status).toBe(200) + expect(handlerMocks.betterAuthPOST).toHaveBeenCalledExactlyOnceWith(request) + } + ) +}) diff --git a/apps/sim/app/api/auth/[...all]/route.ts b/apps/sim/app/api/auth/[...all]/route.ts index 03c3a1514ad..32227242238 100644 --- a/apps/sim/app/api/auth/[...all]/route.ts +++ b/apps/sim/app/api/auth/[...all]/route.ts @@ -16,6 +16,11 @@ export const dynamic = 'force-dynamic' const { GET: betterAuthGET, POST: betterAuthPOST } = toNextJsHandler(auth.handler) const SAFE_ORGANIZATION_POST_PATHS = new Set(['organization/check-slug', 'organization/set-active']) const OAUTH_CALLBACK_PATH_PREFIX = 'oauth2/callback/' +const UNSUPPORTED_OIDC_PATHS = new Set([ + '.well-known/openid-configuration', + 'oauth2/end-session', + 'oauth2/userinfo', +]) /** * SAML protocol endpoints the IdP posts to (`saml2/callback/:id`, @@ -25,6 +30,16 @@ const OAUTH_CALLBACK_PATH_PREFIX = 'oauth2/callback/' */ const SAML_PROTOCOL_POST_PREFIX = 'sso/saml2/' +/** + * Provider endpoints served by the plugin. Token and revocation requests have + * dedicated routes that own their validation and token-family lifecycle. + */ +const OAUTH_PROVIDER_PROTOCOL_POST_PATHS = new Set([ + 'oauth2/consent', + 'oauth2/continue', + 'oauth2/public-client-prelogin', +]) + function getAuthPath(request: NextRequest): string { const pathname = request.nextUrl?.pathname ?? new URL(request.url).pathname return pathname.replace('/api/auth/', '') @@ -71,8 +86,45 @@ function isBlockedSsoMutationPath(path: string): boolean { return path.startsWith('sso/') && !path.startsWith(SAML_PROTOCOL_POST_PREFIX) } +/** + * Client registration and client/consent mutation are not Sim's OAuth surface. + * + * `allowDynamicClientRegistration: false` gates only `/oauth2/register`; the + * plugin's `/oauth2/create-client`, `/update-client`, `/delete-client`, + * `/client/rotate-secret` and `/update-consent` are separate endpoints whose + * only guard is a session, so without this any signed-in user could register a + * client with arbitrary redirect URIs and the full scope set, then phish a + * token out of somebody else — or widen their own grant past what they + * consented to. Clients are operator-created rows (see + * `apps/sim/scripts/create-oauth-client.ts`), and a consent is changed by + * granting or revoking it, never by editing the row. + * + * Deny-by-default, like the SSO block above, so a future plugin version cannot + * introduce another unshadowed mutation endpoint. `oauth2/link` and + * `oauth2/callback/` belong to the existing generic OAuth connector client. + */ +function isBlockedOAuthProviderMutationPath(path: string): boolean { + if ( + !path.startsWith('oauth2/') || + path === 'oauth2/link' || + path.startsWith('oauth2/callback/') + ) { + return false + } + return !OAUTH_PROVIDER_PROTOCOL_POST_PATHS.has(path) +} + +/** Sim exposes OAuth API authorization, not an OpenID Connect identity provider. */ +function unsupportedOidcResponse(): NextResponse { + return NextResponse.json( + { error: 'OpenID Connect is not available.' }, + { status: 404, headers: { 'Cache-Control': 'no-store' } } + ) +} + export const GET = withRouteHandler(async (request: NextRequest) => { const path = getAuthPath(request) + if (UNSUPPORTED_OIDC_PATHS.has(path)) return unsupportedOidcResponse() const credentialGroupProviderId = getCredentialGroupCallbackProviderId(request, path) if (credentialGroupProviderId) { @@ -111,6 +163,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { export const POST = withRouteHandler(async (request: NextRequest) => { const path = getAuthPath(request) + if (UNSUPPORTED_OIDC_PATHS.has(path)) return unsupportedOidcResponse() if (isBlockedOrganizationMutationPath(path)) { return NextResponse.json( @@ -126,5 +179,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } + if (isBlockedOAuthProviderMutationPath(path)) { + return NextResponse.json( + { error: 'OAuth client registration is not available.' }, + { status: 404 } + ) + } + + if (OAUTH_PROVIDER_PROTOCOL_POST_PATHS.has(path) && isAuthDisabled) { + return NextResponse.json( + { error: 'OAuth provider is not enabled' }, + { status: 404, headers: { 'Cache-Control': 'no-store', Pragma: 'no-cache' } } + ) + } + return betterAuthPOST(request) }) diff --git a/apps/sim/app/api/auth/oauth/utils.test.ts b/apps/sim/app/api/auth/oauth/utils.test.ts index 70d2ec4e50c..13200bbf7f6 100644 --- a/apps/sim/app/api/auth/oauth/utils.test.ts +++ b/apps/sim/app/api/auth/oauth/utils.test.ts @@ -177,7 +177,7 @@ describe('OAuth Utils', () => { ).rejects.toThrow('Failed to refresh token') }) - it('should not attempt refresh if no refresh token', async () => { + it('requires reconnection for an expired token without attempting an unavailable refresh', async () => { const mockCredential = { id: 'credential-id', accessToken: 'token', @@ -186,10 +186,11 @@ describe('OAuth Utils', () => { providerId: 'google', } - const result = await refreshTokenIfNeeded('request-id', mockCredential, 'credential-id') + await expect( + refreshTokenIfNeeded('request-id', mockCredential, 'credential-id') + ).rejects.toThrow('OAuth access token expired and cannot be refreshed; reconnect the account') expect(mockRefreshOAuthToken).not.toHaveBeenCalled() - expect(result).toEqual({ accessToken: 'token', refreshed: false }) }) it('keeps a legacy non-expiring Monday credential usable without refreshing it', async () => { diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts index 656b3efe645..358cd1f3753 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts @@ -1,14 +1,22 @@ /** * @vitest-environment node */ -import { createMockRequest } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + createMockRequest, + queueTableRows, + resetDbChainMock, + resetEnvFlagsMock, + schemaMock, + setEnvFlags, +} from '@sim/testing' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import { ForbiddenOperationError } from '@/lib/core/application/forbidden' import { InsufficientWorkspacePermissionsError } from '@/lib/core/application/workspace-authorization' import { OrchestrationError } from '@/lib/core/orchestration/types' import { CredentialConnectionProviderMismatchError } from '@/lib/credentials/application/connection-target' const mocks = vi.hoisted(() => ({ + betterAuthGET: vi.fn(), getSession: vi.fn(), linkAccount: vi.fn(), getBaseUrl: vi.fn(), @@ -21,9 +29,13 @@ const mocks = vi.hoisted(() => ({ getCanonicalScopes: vi.fn(), })) +vi.mock('better-auth/next-js', () => ({ + toNextJsHandler: () => ({ GET: mocks.betterAuthGET }), +})) + vi.mock('@/lib/auth/auth', () => ({ getSession: mocks.getSession, - auth: { api: { oAuth2LinkAccount: mocks.linkAccount } }, + auth: { handler: {}, api: { oAuth2LinkAccount: mocks.linkAccount } }, })) vi.mock('@/lib/core/utils/urls', () => ({ SITE_URL: 'https://www.sim.ai', @@ -44,11 +56,8 @@ vi.mock('@/lib/credentials/application/create-credential-connection', () => ({ execute: mocks.createConnection, }, })) -vi.mock('@/lib/credentials/application/launch-credential-connection', () => ({ - launchCredentialConnection: { - operation: { id: 'credentials.connections.launch' }, - execute: mocks.launchConnection, - }, +vi.mock('@/lib/credentials/application/launch-scoped-credential-connection', () => ({ + launchScopedCredentialConnection: mocks.launchConnection, })) vi.mock('@/lib/oauth/utils', () => ({ getPerRequestOAuthLinkScopes: mocks.getPerRequestScopes, @@ -79,9 +88,13 @@ function linkResponse(url = 'https://provider.example/authorize') { }) } +afterAll(resetEnvFlagsMock) + describe('OAuth2 authorize route', () => { beforeEach(() => { vi.clearAllMocks() + resetDbChainMock() + setEnvFlags({ isAuthDisabled: false }) mocks.getBaseUrl.mockReturnValue(BASE_URL) mocks.getSession.mockResolvedValue({ user: { id: 'user-1' }, @@ -104,6 +117,7 @@ describe('OAuth2 authorize route', () => { }) mocks.linkAccount.mockResolvedValue(linkResponse()) mocks.getPerRequestScopes.mockReturnValue(undefined) + mocks.betterAuthGET.mockResolvedValue(new Response(null, { status: 302 })) mocks.getCanonicalScopes.mockReturnValue([ 'openid', 'profile', @@ -119,26 +133,303 @@ describe('OAuth2 authorize route', () => { mocks.createQuickBooksState.mockReturnValue('signed-state') }) - it('creates a canonical application draft for a legacy connect URL', async () => { - const response = await GET(request({ providerId: 'google-email', workspaceId: WORKSPACE_ID })) + it('forwards a provider request without entering the connector flow', async () => { + const providerRequest = request({ + client_id: 'client-1', + response_type: 'code', + redirect_uri: 'https://client.example/callback', + providerId: 'google-email', + draftId: 'draft-1', + }) - expect(response.headers.get('location')).toBe('https://provider.example/authorize') - expect(mocks.createConnection).toHaveBeenCalledWith( - expect.objectContaining({ - principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, - input: { workspaceId: WORKSPACE_ID, providerId: 'google-email' }, + const response = await GET(providerRequest) + + expect(response.status).toBe(302) + expect(mocks.betterAuthGET).toHaveBeenCalledWith(providerRequest) + expect(mocks.getSession).not.toHaveBeenCalled() + expect(mocks.launchConnection).not.toHaveBeenCalled() + expect(mocks.createConnection).not.toHaveBeenCalled() + }) + + it.each([ + ['https://client.example/callback', 'https://client.example/callback'], + ['http://127.0.0.1/callback', 'http://127.0.0.1:43123/callback'], + ])('returns permission denials to registered callback %s', async (registered, redirectUri) => { + queueTableRows(schemaMock.oauthClient, [{ disabled: false, redirectUris: [registered] }]) + mocks.betterAuthGET.mockResolvedValue( + Response.json( + { + error: 'access_denied', + error_description: 'OAuth apps are restricted for your account.', + }, + { status: 403 } + ) + ) + + const response = await GET( + request({ + client_id: 'client-1', + response_type: 'code', + redirect_uri: redirectUri, + state: 'state-1', }) ) - expect(mocks.linkAccount).toHaveBeenCalledWith( - expect.objectContaining({ - body: expect.objectContaining({ + const location = new URL(response.headers.get('location') ?? '') + + expect(response.status).toBe(302) + expect(response.headers.get('cache-control')).toBe('no-store') + expect(`${location.origin}${location.pathname}`).toBe(redirectUri) + expect(location.searchParams.get('error')).toBe('access_denied') + expect(location.searchParams.get('error_description')).toBe( + 'OAuth apps are restricted for your account.' + ) + expect(location.searchParams.get('state')).toBe('state-1') + expect(location.searchParams.get('iss')).toBe(`${BASE_URL}/api/auth`) + expect(location.searchParams.has('code')).toBe(false) + expect(mocks.getSession).not.toHaveBeenCalled() + }) + + it.each([ + ['missing client', undefined], + ['disabled client', { disabled: true, redirectUris: ['https://client.example/callback'] }], + ['unregistered callback', { disabled: false, redirectUris: ['https://client.example/other'] }], + ])('does not redirect a permission denial for a %s', async (_case, client) => { + if (client) queueTableRows(schemaMock.oauthClient, [client]) + mocks.betterAuthGET.mockResolvedValue( + Response.json({ error: 'access_denied' }, { status: 403 }) + ) + + const response = await GET( + request({ + client_id: 'client-1', + response_type: 'code', + redirect_uri: 'https://client.example/callback', + state: 'state-1', + }) + ) + + expect(response.status).toBe(400) + expect(response.headers.has('location')).toBe(false) + await expect(response.json()).resolves.toEqual({ + error: 'access_denied', + error_description: 'Access denied.', + }) + }) + + it.each([ + [403, '{"error":"invalid_request"}'], + [403, 'Forbidden'], + [400, '{"error":"access_denied"}'], + ])('preserves delegated status %s and body %s', async (status, body) => { + mocks.betterAuthGET.mockResolvedValue(new Response(body, { status })) + + const response = await GET( + request({ + client_id: 'client-1', + response_type: 'code', + redirect_uri: 'https://client.example/callback', + }) + ) + + expect(response.status).toBe(status) + expect(response.headers.has('location')).toBe(false) + await expect(response.text()).resolves.toBe(body) + }) + + it('requires authentication for provider authorization', async () => { + setEnvFlags({ isAuthDisabled: true }) + const response = await GET( + request({ + client_id: 'client-1', + response_type: 'code', + redirect_uri: 'https://client.example/callback', + }) + ) + expect(response.status).toBe(404) + expect(response.headers.get('cache-control')).toBe('no-store') + expect(mocks.betterAuthGET).not.toHaveBeenCalled() + expect(mocks.getSession).not.toHaveBeenCalled() + }) + + it('preserves connector authorization when user authentication is disabled', async () => { + setEnvFlags({ isAuthDisabled: true }) + const response = await GET(request({ draftId: 'draft-1' })) + expect(response.status).toBe(307) + expect(mocks.launchConnection).toHaveBeenCalled() + expect(mocks.linkAccount).toHaveBeenCalled() + expect(mocks.betterAuthGET).not.toHaveBeenCalled() + }) + + it('keeps an OAuth request missing client_id out of the connector flow', async () => { + const response = await GET( + request({ response_type: 'code', redirect_uri: 'https://client.example/callback' }) + ) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ error: 'invalid_request' }) + expect(mocks.getSession).not.toHaveBeenCalled() + expect(mocks.betterAuthGET).not.toHaveBeenCalled() + }) + + it.each(['scope', 'state', 'nonce', 'prompt'])( + 'does not let an isolated %s parameter enter the connector flow', + async (parameter) => { + const response = await GET( + request({ providerId: 'google-email', - callbackURL: expect.stringContaining('credentialDraftId=draft-1'), - }), + workspaceId: WORKSPACE_ID, + [parameter]: 'value', + }) + ) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ error: 'invalid_request' }) + expect(mocks.getSession).not.toHaveBeenCalled() + expect(mocks.createConnection).not.toHaveBeenCalled() + expect(mocks.betterAuthGET).not.toHaveBeenCalled() + } + ) + + it.each([ + 'response_type', + 'client_id', + 'redirect_uri', + 'scope', + 'state', + 'request_uri', + 'code_challenge', + 'code_challenge_method', + 'nonce', + 'prompt', + 'resource', + ])('rejects a repeated OAuth provider %s before Better Auth', async (parameter) => { + const url = new URL('/api/auth/oauth2/authorize', BASE_URL) + url.searchParams.set('client_id', 'sim-cli') + url.searchParams.append(parameter, 'first') + url.searchParams.append(parameter, 'second') + + const response = await GET(createMockRequest('GET', undefined, {}, url.toString())) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ error: 'invalid_request' }) + expect(mocks.betterAuthGET).not.toHaveBeenCalled() + }) + + it.each([ + [{ code_challenge: 'a'.repeat(43) }, 'unpaired challenge'], + [{ code_challenge_method: 'S256' }, 'unpaired method'], + [{ code_challenge: 'a'.repeat(42), code_challenge_method: 'S256' }, 'malformed challenge'], + [{ code_challenge: 'a'.repeat(43), code_challenge_method: 'plain' }, 'unsupported method'], + ])('rejects %s PKCE parameters before Better Auth', async (parameters) => { + const response = await GET( + request({ + client_id: 'sim-cli', + response_type: 'code', + redirect_uri: 'https://client.example/callback', + ...parameters, + }) + ) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ error: 'invalid_request' }) + expect(mocks.betterAuthGET).not.toHaveBeenCalled() + }) + + it('accepts a canonical S256 challenge and rejects unsupported resource audiences', async () => { + const acceptedRequest = request({ + client_id: 'sim-cli', + response_type: 'code', + redirect_uri: 'https://client.example/callback', + code_challenge: 'a'.repeat(43), + code_challenge_method: 'S256', + }) + const accepted = await GET(acceptedRequest) + const resource = await GET( + request({ + client_id: 'sim-cli', + response_type: 'code', + redirect_uri: 'https://client.example/callback', + resource: 'https://api.example.test', }) ) + + expect(accepted.status).toBe(302) + expect(mocks.betterAuthGET).toHaveBeenCalledWith(acceptedRequest) + expect(resource.status).toBe(400) + await expect(resource.json()).resolves.toMatchObject({ error: 'invalid_request' }) + }) + + it('redirects a malformed request only to its registered callback with state and issuer', async () => { + queueTableRows(schemaMock.oauthClient, [ + { disabled: false, redirectUris: ['http://127.0.0.1/callback'] }, + ]) + + const response = await GET( + request({ + client_id: 'sim-cli', + response_type: 'code', + redirect_uri: 'http://127.0.0.1:43123/callback', + state: 'state-1', + code_challenge: 'too-short', + code_challenge_method: 'S256', + }) + ) + const location = new URL(response.headers.get('location') ?? '') + + expect(response.status).toBe(302) + expect(response.headers.get('cache-control')).toBe('no-store') + expect(location.origin).toBe('http://127.0.0.1:43123') + expect(location.searchParams.get('error')).toBe('invalid_request') + expect(location.searchParams.get('state')).toBe('state-1') + expect(location.searchParams.get('iss')).toBe(`${BASE_URL}/api/auth`) + expect(mocks.betterAuthGET).not.toHaveBeenCalled() + }) + + it('returns the authorization-specific error code to a registered callback', async () => { + queueTableRows(schemaMock.oauthClient, [ + { disabled: false, redirectUris: ['https://client.example/callback'] }, + ]) + + const response = await GET( + request({ + client_id: 'client-1', + response_type: 'token', + redirect_uri: 'https://client.example/callback', + state: 'state-1', + }) + ) + const location = new URL(response.headers.get('location') ?? '') + + expect(response.status).toBe(302) + expect(location.searchParams.get('error')).toBe('unsupported_response_type') + expect(location.searchParams.get('state')).toBe('state-1') + expect(mocks.betterAuthGET).not.toHaveBeenCalled() }) + it.each([true, false])( + 'preserves legacy connector linking with authentication disabled=%s', + async (authDisabled) => { + setEnvFlags({ isAuthDisabled: authDisabled }) + const response = await GET(request({ providerId: 'google-email', workspaceId: WORKSPACE_ID })) + + expect(response.headers.get('location')).toBe('https://provider.example/authorize') + expect(mocks.createConnection).toHaveBeenCalledWith( + expect.objectContaining({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: WORKSPACE_ID, providerId: 'google-email' }, + }) + ) + expect(mocks.linkAccount).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ + providerId: 'google-email', + callbackURL: expect.stringContaining('credentialDraftId=draft-1'), + }), + }) + ) + } + ) + it('requires a configured OAuth client before creating a legacy draft', async () => { mocks.requireClient.mockImplementationOnce(() => { throw new Error('OAuth client is not configured') @@ -146,7 +437,7 @@ describe('OAuth2 authorize route', () => { const response = await GET(request({ providerId: 'google-email', workspaceId: WORKSPACE_ID })) - expect(response.headers.get('location')).toBe(`${BASE_URL}/workspace?error=oauth_link_failed`) + expect(response.headers.get('location')).toBe(`${BASE_URL}/home?error=oauth_link_failed`) expect(mocks.requireClient).toHaveBeenCalledWith('google-email') expect(mocks.createConnection).not.toHaveBeenCalled() }) @@ -227,7 +518,7 @@ describe('OAuth2 authorize route', () => { ) expect(response.headers.get('location')).toBe( - `${BASE_URL}/workspace?error=credential_provider_mismatch` + `${BASE_URL}/home?error=credential_provider_mismatch` ) }) @@ -267,9 +558,7 @@ describe('OAuth2 authorize route', () => { }) ) - expect(response.headers.get('location')).toBe( - `${BASE_URL}/workspace?error=workspace_access_denied` - ) + expect(response.headers.get('location')).toBe(`${BASE_URL}/home?error=workspace_access_denied`) }) it('redirects a draft launch infrastructure failure through the browser error contract', async () => { @@ -277,7 +566,7 @@ describe('OAuth2 authorize route', () => { const response = await GET(request({ draftId: 'draft-1' })) - expect(response.headers.get('location')).toBe(`${BASE_URL}/workspace?error=oauth_link_failed`) + expect(response.headers.get('location')).toBe(`${BASE_URL}/home?error=oauth_link_failed`) }) it('routes custom providers through the exact application draft', async () => { diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.ts b/apps/sim/app/api/auth/oauth2/authorize/route.ts index 1a70cc24ad2..c0775f1e1cd 100644 --- a/apps/sim/app/api/auth/oauth2/authorize/route.ts +++ b/apps/sim/app/api/auth/oauth2/authorize/route.ts @@ -1,18 +1,23 @@ import { createLogger } from '@sim/logger' +import { toNextJsHandler } from 'better-auth/next-js' import { type NextRequest, NextResponse } from 'next/server' import { authorizeOAuth2Contract } from '@/lib/api/contracts/oauth-connections' import { parseRequest } from '@/lib/api/server' import { auth, getSession } from '@/lib/auth/auth' +import { oauthAuthorizationErrorResponse } from '@/lib/auth/oauth-authorization-error' +import { validateOAuthPkceAuthorizationRequest } from '@/lib/auth/oauth-protocol-request' import { ForbiddenOperationError } from '@/lib/core/application/forbidden' import { requireConfiguredOAuthClient } from '@/lib/core/config/env-capabilities.server' +import { isAuthDisabled } from '@/lib/core/config/env-flags' import { OrchestrationError } from '@/lib/core/orchestration/types' import { getBaseUrl } from '@/lib/core/utils/urls' import { isSameOrigin } from '@/lib/core/utils/validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { CredentialConnectionProviderMismatchError } from '@/lib/credentials/application/connection-target' import { createCredentialConnection } from '@/lib/credentials/application/create-credential-connection' -import { launchCredentialConnection } from '@/lib/credentials/application/launch-credential-connection' +import { launchScopedCredentialConnection } from '@/lib/credentials/application/launch-scoped-credential-connection' import { OAUTH_CREDENTIAL_DRAFT_CALLBACK_PARAM } from '@/lib/credentials/draft-constants' +import { APP_ENTRY_PATH } from '@/lib/navigation/paths' import { decryptQuickBooksOAuthClientConfig } from '@/lib/oauth/quickbooks-client-config' import { QUICKBOOKS_AUTHORIZATION_URL } from '@/lib/oauth/quickbooks-constants' import { createQuickBooksOAuthState } from '@/lib/oauth/quickbooks-state' @@ -22,10 +27,132 @@ const logger = createLogger('OAuth2Authorize') export const dynamic = 'force-dynamic' +const { GET: betterAuthGET } = toNextJsHandler(auth.handler) + +const OAUTH_AUTHORIZE_PARAMETERS = new Set([ + 'response_type', + 'client_id', + 'redirect_uri', + 'scope', + 'state', + 'request_uri', + 'code_challenge', + 'code_challenge_method', + 'nonce', + 'prompt', + 'resource', +]) + +/** Returns the first ambiguous OAuth authorization parameter. */ +function repeatedOAuthAuthorizeParameter(request: NextRequest): string | null { + for (const name of OAUTH_AUTHORIZE_PARAMETERS) { + if (request.nextUrl.searchParams.getAll(name).length <= 1) continue + return name + } + return null +} + +/** + * Whether this is a client asking Sim to sign its user in — the OAuth provider's + * authorize request — rather than a user linking an external account. + * + * This route sits on the same path Better Auth mounts the provider's authorize + * endpoint, so the catch-all never sees it. Connector links use only the + * contract's draft/provider/workspace fields; any provider-specific parameter + * keeps even a malformed OAuth request out of the credential-linking flow. + */ +function isOAuthProviderAuthorize(request: NextRequest): boolean { + for (const name of OAUTH_AUTHORIZE_PARAMETERS) { + if (request.nextUrl.searchParams.has(name)) return true + } + return false +} + /** - * Browser-initiated entrypoint for linking a generic OAuth2 account. + * Browser-initiated entrypoint for linking a generic OAuth2 account, and the + * OAuth provider's authorize endpoint when the request is a client's. */ export const GET = withRouteHandler(async (request: NextRequest) => { + if (isOAuthProviderAuthorize(request)) { + if (isAuthDisabled) { + return NextResponse.json( + { error: 'OAuth provider is not enabled' }, + { status: 404, headers: { 'Cache-Control': 'no-store', Pragma: 'no-cache' } } + ) + } + const repeatedParameter = repeatedOAuthAuthorizeParameter(request) + if (repeatedParameter) { + return oauthAuthorizationErrorResponse( + request, + 'invalid_request', + `OAuth parameter ${repeatedParameter} appears more than once.` + ) + } + const params = request.nextUrl.searchParams + if (!params.has('client_id')) { + return oauthAuthorizationErrorResponse( + request, + 'invalid_request', + 'The client_id parameter is required.' + ) + } + if (!params.has('redirect_uri')) { + return oauthAuthorizationErrorResponse( + request, + 'invalid_request', + 'The redirect_uri parameter is required.' + ) + } + if (params.has('resource')) { + return oauthAuthorizationErrorResponse( + request, + 'invalid_request', + 'The resource parameter is not supported.' + ) + } + if (params.has('request_uri')) { + return oauthAuthorizationErrorResponse( + request, + 'invalid_request', + 'The request_uri parameter is not supported.' + ) + } + const responseType = params.get('response_type') + if (!responseType) { + return oauthAuthorizationErrorResponse( + request, + 'invalid_request', + 'The response_type parameter is required.' + ) + } + if (responseType !== 'code') { + return oauthAuthorizationErrorResponse( + request, + 'unsupported_response_type', + 'Only the code response type is supported.' + ) + } + const pkceError = validateOAuthPkceAuthorizationRequest(params) + if (pkceError) { + return oauthAuthorizationErrorResponse(request, 'invalid_request', pkceError) + } + const response = await betterAuthGET(request) + if (response.status === 403) { + const body: unknown = await response + .clone() + .json() + .catch(() => null) + if (body && typeof body === 'object' && 'error' in body && body.error === 'access_denied') { + const description = + 'error_description' in body && typeof body.error_description === 'string' + ? body.error_description + : 'Access denied.' + return oauthAuthorizationErrorResponse(request, 'access_denied', description) + } + } + return response + } + const baseUrl = getBaseUrl() const session = await getSession() @@ -45,18 +172,20 @@ export const GET = withRouteHandler(async (request: NextRequest) => { let { providerId, workspaceId, callbackURL: requestedCallback, credentialId } = parsed.data.query try { + let organizationId: string | undefined let fromConnectionDraft = false let connectionDraftId: string | undefined let encryptedQuickBooksClientConfig: string | null | undefined if (draftId) { try { - const { draft } = await launchCredentialConnection.execute({ + const { draft } = await launchScopedCredentialConnection({ principal, input: { draftId }, request, }) providerId = draft.providerId - workspaceId = draft.workspaceId + workspaceId = draft.workspaceId ?? undefined + organizationId = draft.organizationId ?? undefined credentialId = draft.credentialId ?? undefined connectionDraftId = draft.id encryptedQuickBooksClientConfig = draft.oauthConfig @@ -64,11 +193,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => { } catch (error) { if (!(error instanceof OrchestrationError)) throw error logger.warn('Rejected OAuth connection draft', { userId, draftId, code: error.code }) - return NextResponse.redirect(`${baseUrl}/workspace?error=oauth_link_invalid`) + return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=oauth_link_invalid`) } } - if (!providerId || !workspaceId) { + if (!providerId || (!workspaceId && !organizationId)) { throw new Error('Validated OAuth authorization request is missing its target') } if (providerId !== 'quickbooks') { @@ -83,9 +212,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => { : connectionCompleteUrl.toString() : requestedCallback?.startsWith(`${baseUrl}/`) ? requestedCallback - : `${baseUrl}/workspace` + : `${baseUrl}${APP_ENTRY_PATH}` if (!fromConnectionDraft) { + if (!workspaceId) throw new Error('Workspace OAuth launch is missing its owner') try { const connection = await createCredentialConnection.execute({ principal, @@ -100,22 +230,24 @@ export const GET = withRouteHandler(async (request: NextRequest) => { connectionDraftId = connection.draftId } catch (error) { if (error instanceof CredentialConnectionProviderMismatchError) { - return NextResponse.redirect(`${baseUrl}/workspace?error=credential_provider_mismatch`) + return NextResponse.redirect( + `${baseUrl}${APP_ENTRY_PATH}?error=credential_provider_mismatch` + ) } if ( credentialId && error instanceof ForbiddenOperationError && error.detailCode === 'CREDENTIAL_ADMIN_ACCESS_REQUIRED' ) { - return NextResponse.redirect(`${baseUrl}/workspace?error=credential_access_denied`) + return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=credential_access_denied`) } if (error instanceof OrchestrationError && error.code === 'not_found') { return NextResponse.redirect( - `${baseUrl}/workspace?error=${credentialId ? 'credential_access_denied' : 'workspace_access_denied'}` + `${baseUrl}${APP_ENTRY_PATH}?error=${credentialId ? 'credential_access_denied' : 'workspace_access_denied'}` ) } if (error instanceof OrchestrationError && error.code === 'forbidden') { - return NextResponse.redirect(`${baseUrl}/workspace?error=workspace_access_denied`) + return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=workspace_access_denied`) } throw error } @@ -127,7 +259,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (providerId === 'quickbooks') { if (!encryptedQuickBooksClientConfig) { - const { draft } = await launchCredentialConnection.execute({ + const { draft } = await launchScopedCredentialConnection({ principal, input: { draftId: connectionDraftId }, request, @@ -183,7 +315,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { providerId, status: linkResponse.status, }) - return NextResponse.redirect(`${baseUrl}/workspace?error=oauth_link_failed`) + return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=oauth_link_failed`) } const response = NextResponse.redirect(payload.url) @@ -198,6 +330,6 @@ export const GET = withRouteHandler(async (request: NextRequest) => { return response } catch (error) { logger.error('Failed to initiate OAuth2 authorization', { providerId, error }) - return NextResponse.redirect(`${baseUrl}/workspace?error=oauth_link_failed`) + return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=oauth_link_failed`) } }) diff --git a/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts b/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts index 19284a950fc..afca4590c1f 100644 --- a/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts +++ b/apps/sim/app/api/auth/oauth2/callback/instagram/route.ts @@ -18,6 +18,7 @@ import { getBaseUrl } from '@/lib/core/utils/urls' import { isSameOrigin } from '@/lib/core/utils/validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { processCredentialDraft } from '@/lib/credentials/draft-processor' +import { APP_ENTRY_PATH } from '@/lib/navigation/paths' import { safeAccountInsert } from '@/lib/oauth/credential-service' import { parseInstagramLongLivedToken, @@ -52,7 +53,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { try { const session = await getSession() if (!session?.user?.id) { - return clearOAuthCookies(NextResponse.redirect(`${baseUrl}/workspace?error=unauthorized`)) + return clearOAuthCookies( + NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=unauthorized`) + ) } const parsed = await parseRequest(instagramCallbackContract, request, {}) @@ -68,7 +71,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { error_description, }) return clearOAuthCookies( - NextResponse.redirect(`${baseUrl}/workspace?error=instagram_access_denied`) + NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=instagram_access_denied`) ) } @@ -79,7 +82,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { hasCookieState: Boolean(cookieState), }) return clearOAuthCookies( - NextResponse.redirect(`${baseUrl}/workspace?error=instagram_state_mismatch`) + NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=instagram_state_mismatch`) ) } @@ -90,7 +93,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (!code) { logger.error('No authorization code received from Instagram') return clearOAuthCookies( - NextResponse.redirect(`${baseUrl}/workspace?error=instagram_no_code`) + NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=instagram_no_code`) ) } @@ -123,7 +126,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { error: errorText, }) return clearOAuthCookies( - NextResponse.redirect(`${baseUrl}/workspace?error=instagram_token_error`) + NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=instagram_token_error`) ) } @@ -136,7 +139,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (!shortLived) { logger.error('Instagram short-lived token response was invalid') return clearOAuthCookies( - NextResponse.redirect(`${baseUrl}/workspace?error=instagram_no_token`) + NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=instagram_no_token`) ) } @@ -160,7 +163,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { error: errorText, }) return clearOAuthCookies( - NextResponse.redirect(`${baseUrl}/workspace?error=instagram_exchange_error`) + NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=instagram_exchange_error`) ) } @@ -174,7 +177,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (!longLived) { logger.error('Instagram long-lived token response was invalid') return clearOAuthCookies( - NextResponse.redirect(`${baseUrl}/workspace?error=instagram_no_long_lived`) + NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=instagram_no_long_lived`) ) } @@ -199,7 +202,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { error: errorText, }) return clearOAuthCookies( - NextResponse.redirect(`${baseUrl}/workspace?error=instagram_profile_error`) + NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=instagram_profile_error`) ) } @@ -212,7 +215,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (!profile) { logger.error('Instagram profile response was invalid') return clearOAuthCookies( - NextResponse.redirect(`${baseUrl}/workspace?error=instagram_profile_error`) + NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=instagram_profile_error`) ) } @@ -222,7 +225,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (!igUserId) { logger.error('Instagram profile response missing user_id', { profile }) return clearOAuthCookies( - NextResponse.redirect(`${baseUrl}/workspace?error=instagram_no_user_id`) + NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=instagram_no_user_id`) ) } @@ -311,7 +314,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const returnUrlCookie = request.cookies.get(INSTAGRAM_RETURN_URL_COOKIE)?.value const redirectUrl = - returnUrlCookie && isSameOrigin(returnUrlCookie) ? returnUrlCookie : `${baseUrl}/workspace` + returnUrlCookie && isSameOrigin(returnUrlCookie) + ? returnUrlCookie + : `${baseUrl}${APP_ENTRY_PATH}` const finalUrl = new URL(redirectUrl) finalUrl.searchParams.set('instagram_connected', 'true') @@ -322,6 +327,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => { error instanceof EnvCapabilityConfigurationError && error.capabilityId === 'oauth' ? 'instagram_config_error' : 'instagram_callback_error' - return clearOAuthCookies(NextResponse.redirect(`${baseUrl}/workspace?error=${errorCode}`)) + return clearOAuthCookies( + NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=${errorCode}`) + ) } }) diff --git a/apps/sim/app/api/auth/oauth2/callback/quickbooks/route.test.ts b/apps/sim/app/api/auth/oauth2/callback/quickbooks/route.test.ts index 51a36fd3c98..1dd57e3d27a 100644 --- a/apps/sim/app/api/auth/oauth2/callback/quickbooks/route.test.ts +++ b/apps/sim/app/api/auth/oauth2/callback/quickbooks/route.test.ts @@ -140,7 +140,7 @@ describe('QuickBooks OAuth callback', () => { expect(mockCompleteQuickBooksConnection).not.toHaveBeenCalled() expect(response.headers.get('location')).toBe( - 'https://sim.test/workspace?error=quickbooks_callback_error' + 'https://sim.test/home?error=quickbooks_callback_error' ) }) }) diff --git a/apps/sim/app/api/auth/oauth2/callback/quickbooks/route.ts b/apps/sim/app/api/auth/oauth2/callback/quickbooks/route.ts index 675e7e8a076..f232cb655f6 100644 --- a/apps/sim/app/api/auth/oauth2/callback/quickbooks/route.ts +++ b/apps/sim/app/api/auth/oauth2/callback/quickbooks/route.ts @@ -7,6 +7,7 @@ import { getBaseUrl } from '@/lib/core/utils/urls' import { isSameOrigin } from '@/lib/core/utils/validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { completeQuickBooksConnection } from '@/lib/credentials/application/complete-quickbooks-connection' +import { APP_ENTRY_PATH } from '@/lib/navigation/paths' import { normalizeQuickBooksRealmId } from '@/lib/oauth/quickbooks' import { parseQuickBooksOAuthState } from '@/lib/oauth/quickbooks-state' @@ -16,7 +17,7 @@ export const dynamic = 'force-dynamic' export const GET = withRouteHandler(async (request: NextRequest) => { const baseUrl = getBaseUrl() - const fallbackUrl = `${baseUrl}/workspace` + const fallbackUrl = `${baseUrl}${APP_ENTRY_PATH}` let validatedReturnUrl: URL | null = null try { diff --git a/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts b/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts index 8447e56d48d..7df3318106e 100644 --- a/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts +++ b/apps/sim/app/api/auth/oauth2/callback/shopify/route.ts @@ -12,6 +12,7 @@ import { requireConfiguredOAuthClient } from '@/lib/core/config/env-capabilities import { getBaseUrl } from '@/lib/core/utils/urls' import { isSameOrigin } from '@/lib/core/utils/validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { APP_ENTRY_PATH } from '@/lib/navigation/paths' import { completeShopifyOAuthConnection } from '@/lib/oauth/shopify' import { parseShopifyOAuthState } from '@/lib/oauth/shopify-state' @@ -63,7 +64,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { try { const session = await getSession() if (!session?.user?.id) { - return NextResponse.redirect(`${baseUrl}/workspace?error=unauthorized`) + return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=unauthorized`) } const { searchParams } = request.nextUrl @@ -79,28 +80,28 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (!validateHmac(searchParams, clientSecret)) { logger.error('HMAC validation failed in Shopify OAuth callback') - return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_hmac_invalid`) + return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=shopify_hmac_invalid`) } if (!state) { logger.error('Missing state in Shopify OAuth callback') - return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_state_mismatch`) + return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=shopify_state_mismatch`) } if (!code) { logger.error('No code received from Shopify') - return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_no_code`) + return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=shopify_no_code`) } const shopDomain = shop if (!shopDomain) { logger.error('No shop domain available') - return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_no_shop`) + return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=shopify_no_shop`) } if (!shopifyShopDomainSchema.safeParse(shopDomain).success) { logger.error('Invalid shop domain format:', { shopDomain }) - return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_invalid_shop`) + return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=shopify_invalid_shop`) } const { draftId, returnUrl } = parseShopifyOAuthState({ @@ -128,7 +129,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { status: tokenResponse.status, body: errorText, }) - return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_token_error`) + return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=shopify_token_error`) } const tokenData = await tokenResponse.json() @@ -142,7 +143,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (!accessToken) { logger.error('No access token in response') - return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_no_token`) + return NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=shopify_no_token`) } await completeShopifyOAuthConnection({ @@ -157,7 +158,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { if (returnUrl && !isSameOrigin(returnUrl)) { throw new Error('Shopify OAuth state contains an invalid return URL') } - const redirectUrl = returnUrl ?? `${baseUrl}/workspace` + const redirectUrl = returnUrl ?? `${baseUrl}${APP_ENTRY_PATH}` const finalUrl = new URL(redirectUrl) finalUrl.searchParams.set('shopify_connected', 'true') @@ -169,7 +170,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ? 'shopify_config_error' : 'shopify_callback_error' return clearShopifyOAuthCookies( - NextResponse.redirect(`${baseUrl}/workspace?error=${errorCode}`) + NextResponse.redirect(`${baseUrl}${APP_ENTRY_PATH}?error=${errorCode}`) ) } }) diff --git a/apps/sim/app/api/auth/oauth2/revoke/route.test.ts b/apps/sim/app/api/auth/oauth2/revoke/route.test.ts new file mode 100644 index 00000000000..32c5ff072ea --- /dev/null +++ b/apps/sim/app/api/auth/oauth2/revoke/route.test.ts @@ -0,0 +1,83 @@ +/** + * @vitest-environment node + */ +import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { NextRequest } from 'next/server' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + rateLimit: vi.fn(async () => null), + revoke: vi.fn(), +})) + +vi.mock('@/lib/core/rate-limiter', () => ({ enforceIpRateLimit: mocks.rateLimit })) +vi.mock('@/lib/auth/oauth-token-family', () => ({ revokeOAuthToken: mocks.revoke })) + +import { POST } from '@/app/api/auth/oauth2/revoke/route' + +function revokeRequest(body: string) { + return new NextRequest('http://localhost/api/auth/oauth2/revoke', { + method: 'POST', + body, + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + }) +} + +afterAll(resetEnvFlagsMock) + +describe('OAuth revocation route', () => { + beforeEach(() => { + vi.clearAllMocks() + setEnvFlags({ isAuthDisabled: false }) + mocks.revoke.mockResolvedValue({ success: true, value: undefined }) + }) + + it('returns the empty RFC 7009 success response for known or unknown tokens', async () => { + const response = await POST( + revokeRequest('client_id=sim-cli&token=sim_ort_current&token_type_hint=not-a-real-hint') + ) + expect(response.status).toBe(200) + await expect(response.text()).resolves.toBe('') + expect(mocks.revoke).toHaveBeenCalledWith({ + credentials: { clientId: 'sim-cli', method: 'none' }, + token: 'sim_ort_current', + }) + }) + + it('requires authentication before revocation admission or protected work', async () => { + setEnvFlags({ isAuthDisabled: true }) + const response = await POST(revokeRequest('client_id=sim-cli&token=sim_ort_current')) + expect(response.status).toBe(404) + expect(response.headers.get('cache-control')).toBe('no-store') + expect(mocks.revoke).not.toHaveBeenCalled() + expect(mocks.rateLimit).not.toHaveBeenCalled() + }) + + it('returns a Basic challenge for Basic client-authentication failure', async () => { + mocks.revoke.mockResolvedValue({ + success: false, + error: 'invalid_client', + description: 'Client authentication failed.', + }) + const basic = Buffer.from('client:wrong').toString('base64') + const request = revokeRequest('token=sim_ort_current') + request.headers.set('authorization', `Basic ${basic}`) + const response = await POST(request) + expect(response.status).toBe(401) + expect(response.headers.get('www-authenticate')).toContain('Basic') + }) + + it('normalizes an unexpected revocation failure', async () => { + mocks.revoke.mockRejectedValueOnce(new Error('database details')) + + const response = await POST(revokeRequest('client_id=sim-cli&token=sim_ort_current')) + + expect(response.status).toBe(500) + expect(response.headers.get('cache-control')).toBe('no-store') + expect(response.headers.get('pragma')).toBe('no-cache') + await expect(response.json()).resolves.toEqual({ + error: 'server_error', + error_description: 'Revocation endpoint failed.', + }) + }) +}) diff --git a/apps/sim/app/api/auth/oauth2/revoke/route.ts b/apps/sim/app/api/auth/oauth2/revoke/route.ts new file mode 100644 index 00000000000..dcb4653691a --- /dev/null +++ b/apps/sim/app/api/auth/oauth2/revoke/route.ts @@ -0,0 +1,67 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { + oauthErrorResponse, + oauthProtocolErrorResponse, + oauthRevocationSuccessResponse, + parseOAuthFormRequest, +} from '@/lib/auth/oauth-protocol-request' +import { revokeOAuthToken } from '@/lib/auth/oauth-token-family' +import { isAuthDisabled } from '@/lib/core/config/env-flags' +import { enforceIpRateLimit, type TokenBucketConfig } from '@/lib/core/rate-limiter' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('OAuthRevocationEndpoint') + +const REVOKE_RATE_LIMIT: TokenBucketConfig = { + maxTokens: 30, + refillRate: 30, + refillIntervalMs: 60_000, +} + +/** Revokes one opaque access token or the complete family named by a refresh token. */ +export const POST = withRouteHandler(async (request: NextRequest) => { + if (isAuthDisabled) { + return NextResponse.json( + { error: 'OAuth provider is not enabled' }, + { status: 404, headers: { 'Cache-Control': 'no-store', Pragma: 'no-cache' } } + ) + } + + try { + const rateLimited = await enforceIpRateLimit( + 'oauth-provider-revoke', + request, + REVOKE_RATE_LIMIT + ) + if (rateLimited) { + rateLimited.headers.set('Cache-Control', 'no-store') + rateLimited.headers.set('Pragma', 'no-cache') + return rateLimited + } + const parsed = await parseOAuthFormRequest(request) + if (!parsed.success) return parsed.response + + if (!parsed.value.credentials) { + return oauthErrorResponse('invalid_client', 'Client authentication is required.') + } + const token = parsed.value.form.get('token') + if (!token) return oauthErrorResponse('invalid_request', 'Token is required.') + + const result = await revokeOAuthToken({ credentials: parsed.value.credentials, token }) + if (!result.success) { + return oauthProtocolErrorResponse( + result.error, + result.description, + parsed.value.credentials.method + ) + } + return oauthRevocationSuccessResponse() + } catch (error) { + logger.error('OAuth revocation endpoint failed', { error: toError(error) }) + return oauthErrorResponse('server_error', 'Revocation endpoint failed.', 500) + } +}) diff --git a/apps/sim/app/api/auth/oauth2/token/route.postgres.test.ts b/apps/sim/app/api/auth/oauth2/token/route.postgres.test.ts new file mode 100644 index 00000000000..8a9ee8e0ea8 --- /dev/null +++ b/apps/sim/app/api/auth/oauth2/token/route.postgres.test.ts @@ -0,0 +1,535 @@ +/** + * @vitest-environment node + */ +import { randomBytes } from 'node:crypto' +import { envFlagsMock } from '@sim/testing/mocks/env-flags.mock' +import { NextRequest } from 'next/server' +import { describe, expect, it, vi } from 'vitest' + +vi.unmock('@sim/db') +vi.unmock('@sim/db/schema') +vi.unmock('drizzle-orm') +vi.unmock('@/lib/auth') +vi.mock('@/lib/core/config/env-flags', () => ({ + ...envFlagsMock, + isHosted: true, + isBillingEnabled: true, +})) + +const databaseUrl = process.env.OAUTH_TOKEN_FAMILY_TEST_DATABASE_URL + +interface TokenResponseBody { + access_token: string + refresh_token: string + scope: string +} + +describe.skipIf(!databaseUrl)('OAuth token route in PostgreSQL', () => { + it('issues, rotates, contains replay, and revokes a real Better Auth PKCE grant', async () => { + process.env.DATABASE_URL = databaseUrl + const authSecret = 'test-secret-that-is-at-least-32-chars-long' + process.env.BETTER_AUTH_SECRET = authSecret + + const [ + { db }, + schema, + { eq, inArray, like }, + { makeSignature }, + { auth }, + { POST: exchangeToken }, + { POST: revokeToken }, + tokenStore, + provider, + { requestUtilsMockFns }, + { isCapabilityWithheldForUser }, + ] = await Promise.all([ + import('@sim/db'), + import('@sim/db/schema'), + import('drizzle-orm'), + import('better-auth/crypto'), + import('@/lib/auth'), + import('@/app/api/auth/oauth2/token/route'), + import('@/app/api/auth/oauth2/revoke/route'), + import('@/lib/auth/oauth-access-token'), + import('@/lib/auth/oauth-provider'), + import('@sim/testing/mocks/request.mock'), + import('@/lib/permission-groups/user-scope.server'), + ]) + + const testId = randomBytes(8).toString('hex') + const userId = `oauth-route-test-user-${testId}` + const sessionId = `oauth-route-test-session-${testId}` + const sessionToken = `oauth-route-test-session-token-${testId}` + const consentId = `oauth-route-test-consent-${testId}` + const organizationId = `oauth-route-test-org-${testId}` + const groupId = `oauth-route-test-group-${testId}` + const email = `oauth-route-${testId}@example.com` + const clientIp = `192.0.2.${Number.parseInt(testId.slice(0, 2), 16) || 1}` + const baseUrl = 'https://test.sim.ai' + const redirectUri = `http://127.0.0.1:${40_000 + (Number.parseInt(testId.slice(0, 4), 16) % 20_000)}/callback` + const grantedScopes = ['offline_access', 'api:read', 'api:write'] + const issuedCodeHashes: string[] = [] + + const signature = await makeSignature(sessionToken, authSecret) + const sessionCookie = `__Secure-better-auth.session_token=${encodeURIComponent(`${sessionToken}.${signature}`)}` + + const createFormRequest = (path: string, form: URLSearchParams) => + new NextRequest(`${baseUrl}${path}`, { + method: 'POST', + body: form.toString(), + headers: { + 'content-type': 'application/x-www-form-urlencoded', + 'x-forwarded-for': clientIp, + }, + }) + + const createAuthorizeUrl = async (verifier: string) => { + const challenge = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)) + const authorizeUrl = new URL('/api/auth/oauth2/authorize', baseUrl) + authorizeUrl.searchParams.set('client_id', provider.SIM_CLI_CLIENT_ID) + authorizeUrl.searchParams.set('response_type', 'code') + authorizeUrl.searchParams.set('redirect_uri', redirectUri) + authorizeUrl.searchParams.set('scope', grantedScopes.join(' ')) + authorizeUrl.searchParams.set('code_challenge', Buffer.from(challenge).toString('base64url')) + authorizeUrl.searchParams.set('code_challenge_method', 'S256') + authorizeUrl.searchParams.set('state', `state-${testId}`) + return authorizeUrl + } + + const issueAuthorizationCode = async (verifier: string): Promise => { + const authorizeUrl = await createAuthorizeUrl(verifier) + const response = await auth.handler( + new Request(authorizeUrl, { headers: { cookie: sessionCookie } }) + ) + expect(response.status).toBe(302) + const location = response.headers.get('location') + expect(location).toBeTruthy() + const code = new URL(location as string, baseUrl).searchParams.get('code') + expect(code, 'Expected an authorization code redirect').toBeTruthy() + issuedCodeHashes.push(tokenStore.hashOAuthToken(code as string)) + const authorizationCodes = await db + .select({ identifier: schema.verification.identifier }) + .from(schema.verification) + .where(like(schema.verification.value, `%${userId}%`)) + expect(authorizationCodes).toHaveLength(1) + return code as string + } + + const exchangeAuthorizationCode = async (code: string, verifier: string) => { + const response = await exchangeToken( + createFormRequest( + '/api/auth/oauth2/token', + new URLSearchParams({ + grant_type: 'authorization_code', + client_id: provider.SIM_CLI_CLIENT_ID, + code, + code_verifier: verifier, + redirect_uri: redirectUri, + }) + ) + ) + expect(response.status).toBe(200) + return (await response.json()) as TokenResponseBody + } + + requestUtilsMockFns.mockGetClientIp.mockReturnValue(clientIp) + const now = new Date() + await db.insert(schema.user).values({ + id: userId, + name: 'OAuth route integration test', + email, + emailVerified: true, + createdAt: now, + updatedAt: now, + }) + const sessionExpiresAt = new Date(now.getTime() + 86_400_000) + await db.insert(schema.session).values({ + id: sessionId, + token: sessionToken, + userId, + expiresAt: sessionExpiresAt, + createdAt: now, + updatedAt: now, + }) + await db.insert(schema.oauthConsent).values({ + id: consentId, + clientId: provider.SIM_CLI_CLIENT_ID, + userId, + referenceId: null, + scopes: grantedScopes, + createdAt: now, + updatedAt: now, + }) + + try { + await db.insert(schema.organization).values({ + id: organizationId, + name: 'OAuth route permission fixture', + slug: organizationId, + createdAt: now, + }) + await db + .insert(schema.member) + .values({ id: `oauth-route-member-${testId}`, userId, organizationId, role: 'owner' }) + await db + .insert(schema.userStats) + .values({ id: `oauth-route-stats-${testId}`, userId, billingBlocked: false }) + await db.insert(schema.subscription).values({ + id: `oauth-route-subscription-${testId}`, + plan: 'enterprise', + referenceId: organizationId, + status: 'active', + seats: 5, + periodStart: now, + periodEnd: sessionExpiresAt, + metadata: { + plan: 'enterprise', + referenceId: organizationId, + seats: 5, + monthlyPrice: 100, + }, + }) + await db.insert(schema.permissionGroup).values({ + id: groupId, + organizationId, + createdBy: userId, + name: 'Default', + isDefault: true, + config: {}, + }) + expect(await isCapabilityWithheldForUser(userId, 'oauth_apps.use')).toBe(false) + const firstVerifier = `${testId}-first-verifier-with-more-than-forty-three-characters` + const firstTokens = await exchangeAuthorizationCode( + await issueAuthorizationCode(firstVerifier), + firstVerifier + ) + const [sessionAfterAuthorization] = await db + .select({ expiresAt: schema.session.expiresAt }) + .from(schema.session) + .where(eq(schema.session.id, sessionId)) + expect(sessionAfterAuthorization?.expiresAt).toEqual(sessionExpiresAt) + expect( + await db + .select({ id: schema.verification.id }) + .from(schema.verification) + .where(like(schema.verification.value, `%${userId}%`)) + ).toHaveLength(0) + + expect(firstTokens.access_token).toMatch(/^sim_oat_/) + expect(firstTokens.refresh_token).toMatch(/^sim_ort_/) + expect(firstTokens.scope).toBe(grantedScopes.join(' ')) + + const firstAccessHash = tokenStore.hashOAuthToken( + firstTokens.access_token.slice(provider.OAUTH_ACCESS_TOKEN_PREFIX.length) + ) + const firstRefreshHash = tokenStore.hashOAuthToken( + firstTokens.refresh_token.slice(provider.OAUTH_REFRESH_TOKEN_PREFIX.length) + ) + const [firstRefresh] = await db + .select({ + id: schema.oauthRefreshToken.id, + token: schema.oauthRefreshToken.token, + familyId: schema.oauthRefreshToken.familyId, + generation: schema.oauthRefreshToken.generation, + scopes: schema.oauthRefreshToken.scopes, + }) + .from(schema.oauthRefreshToken) + .where(eq(schema.oauthRefreshToken.token, firstRefreshHash)) + const [firstAccess] = await db + .select({ + token: schema.oauthAccessToken.token, + refreshId: schema.oauthAccessToken.refreshId, + scopes: schema.oauthAccessToken.scopes, + }) + .from(schema.oauthAccessToken) + .where(eq(schema.oauthAccessToken.token, firstAccessHash)) + const [firstFamily] = await db + .select({ + id: schema.oauthTokenFamily.id, + consentId: schema.oauthTokenFamily.consentId, + currentGeneration: schema.oauthTokenFamily.currentGeneration, + }) + .from(schema.oauthTokenFamily) + .where(eq(schema.oauthTokenFamily.id, firstRefresh?.familyId ?? 'missing')) + + expect(firstRefresh).toMatchObject({ + token: firstRefreshHash, + generation: 0, + scopes: grantedScopes, + }) + expect(firstRefresh?.token).not.toBe(firstTokens.refresh_token) + expect(firstAccess).toEqual({ + token: firstAccessHash, + refreshId: firstRefresh?.id, + scopes: grantedScopes, + }) + expect(firstAccess?.token).not.toBe(firstTokens.access_token) + expect(firstFamily).toEqual({ + id: firstRefresh?.id, + consentId, + currentGeneration: 0, + }) + + const narrowedRefresh = await exchangeToken( + createFormRequest( + '/api/auth/oauth2/token', + new URLSearchParams({ + grant_type: 'refresh_token', + client_id: provider.SIM_CLI_CLIENT_ID, + refresh_token: firstTokens.refresh_token, + scope: 'offline_access api:read', + }) + ) + ) + expect(narrowedRefresh.status).toBe(200) + const narrowedTokens = (await narrowedRefresh.json()) as TokenResponseBody + expect(narrowedTokens.scope).toBe('offline_access api:read') + + const nextRefreshHash = tokenStore.hashOAuthToken( + narrowedTokens.refresh_token.slice(provider.OAUTH_REFRESH_TOKEN_PREFIX.length) + ) + const nextAccessHash = tokenStore.hashOAuthToken( + narrowedTokens.access_token.slice(provider.OAUTH_ACCESS_TOKEN_PREFIX.length) + ) + const [nextRefresh] = await db + .select({ + id: schema.oauthRefreshToken.id, + familyId: schema.oauthRefreshToken.familyId, + generation: schema.oauthRefreshToken.generation, + scopes: schema.oauthRefreshToken.scopes, + }) + .from(schema.oauthRefreshToken) + .where(eq(schema.oauthRefreshToken.token, nextRefreshHash)) + const [nextAccess] = await db + .select({ + refreshId: schema.oauthAccessToken.refreshId, + scopes: schema.oauthAccessToken.scopes, + }) + .from(schema.oauthAccessToken) + .where(eq(schema.oauthAccessToken.token, nextAccessHash)) + + expect(nextRefresh).toMatchObject({ + familyId: firstRefresh?.id, + generation: 1, + scopes: grantedScopes, + }) + expect(nextAccess).toEqual({ + refreshId: nextRefresh?.id, + scopes: ['offline_access', 'api:read'], + }) + + const replay = await exchangeToken( + createFormRequest( + '/api/auth/oauth2/token', + new URLSearchParams({ + grant_type: 'refresh_token', + client_id: provider.SIM_CLI_CLIENT_ID, + refresh_token: firstTokens.refresh_token, + }) + ) + ) + expect(replay.status).toBe(400) + await expect(replay.json()).resolves.toMatchObject({ error: 'invalid_grant' }) + expect( + await db + .select({ id: schema.oauthTokenFamily.id }) + .from(schema.oauthTokenFamily) + .where(eq(schema.oauthTokenFamily.id, firstRefresh?.id ?? 'missing')) + ).toHaveLength(0) + + const secondVerifier = `${testId}-second-verifier-with-more-than-forty-three-characters` + const secondTokens = await exchangeAuthorizationCode( + await issueAuthorizationCode(secondVerifier), + secondVerifier + ) + expect( + await db + .select({ id: schema.verification.id }) + .from(schema.verification) + .where(like(schema.verification.value, `%${userId}%`)) + ).toHaveLength(0) + const secondRefreshHash = tokenStore.hashOAuthToken( + secondTokens.refresh_token.slice(provider.OAUTH_REFRESH_TOKEN_PREFIX.length) + ) + const [secondRefresh] = await db + .select({ familyId: schema.oauthRefreshToken.familyId }) + .from(schema.oauthRefreshToken) + .where(eq(schema.oauthRefreshToken.token, secondRefreshHash)) + + const revoked = await revokeToken( + createFormRequest( + '/api/auth/oauth2/revoke', + new URLSearchParams({ + client_id: provider.SIM_CLI_CLIENT_ID, + token: secondTokens.refresh_token, + }) + ) + ) + expect(revoked.status).toBe(200) + expect(await revoked.text()).toBe('') + expect( + await db + .select({ id: schema.oauthTokenFamily.id }) + .from(schema.oauthTokenFamily) + .where(eq(schema.oauthTokenFamily.id, secondRefresh?.familyId ?? 'missing')) + ).toHaveLength(0) + + const racingVerifier = `${testId}-racing-verifier-with-more-than-forty-three-characters` + const racingCode = await issueAuthorizationCode(racingVerifier) + const exchangeSameCode = () => + exchangeToken( + createFormRequest( + '/api/auth/oauth2/token', + new URLSearchParams({ + grant_type: 'authorization_code', + client_id: provider.SIM_CLI_CLIENT_ID, + code: racingCode, + code_verifier: racingVerifier, + redirect_uri: redirectUri, + }) + ) + ) + const racingResponses = await Promise.all([exchangeSameCode(), exchangeSameCode()]) + expect(racingResponses.map((response) => response.status).sort()).toEqual([200, 400]) + const rejectedExchange = racingResponses.find((response) => response.status === 400) + await expect(rejectedExchange?.json()).resolves.toMatchObject({ error: 'invalid_grant' }) + expect( + await db + .select({ id: schema.oauthTokenFamily.id }) + .from(schema.oauthTokenFamily) + .where(eq(schema.oauthTokenFamily.userId, userId)) + ).toHaveLength(1) + + const activeTokens = (await racingResponses + .find((response) => response.status === 200) + ?.json()) as TokenResponseBody + const withheldVerifier = `${testId}-withheld-verifier-with-more-than-forty-three-characters` + const withheldCode = await issueAuthorizationCode(withheldVerifier) + const consentUrl = await createAuthorizeUrl(withheldVerifier) + consentUrl.searchParams.set('prompt', 'consent') + const consentPage = await auth.handler( + new Request(consentUrl, { headers: { cookie: sessionCookie } }) + ) + expect(consentPage.status).toBe(302) + const signedQuery = new URL(consentPage.headers.get('location')!, baseUrl).search.slice(1) + expect(new URLSearchParams(signedQuery).has('sig')).toBe(true) + const submitConsent = (accept: boolean) => + auth.handler( + new Request(`${baseUrl}/api/auth/oauth2/consent`, { + method: 'POST', + headers: { cookie: sessionCookie, 'content-type': 'application/json', origin: baseUrl }, + body: JSON.stringify({ accept, oauth_query: signedQuery }), + }) + ) + + await db + .update(schema.permissionGroup) + .set({ config: { disableOAuthAppAccess: true } }) + .where(eq(schema.permissionGroup.id, groupId)) + expect(await isCapabilityWithheldForUser(userId, 'oauth_apps.use')).toBe(true) + const blockedCachedConsent = await auth.handler( + new Request(await createAuthorizeUrl(withheldVerifier), { + headers: { cookie: sessionCookie }, + }) + ) + expect(blockedCachedConsent.status).toBe(403) + await expect(blockedCachedConsent.json()).resolves.toMatchObject({ error: 'access_denied' }) + const blockedAccept = await submitConsent(true) + expect(blockedAccept.status).toBe(403) + await expect(blockedAccept.json()).resolves.toMatchObject({ error: 'access_denied' }) + + const denial = await submitConsent(false) + expect(denial.status).toBe(200) + const denialBody = (await denial.json()) as { redirect: boolean; url: string } + expect(denialBody.redirect).toBe(true) + const denialUrl = new URL(denialBody.url) + expect(`${denialUrl.origin}${denialUrl.pathname}`).toBe(redirectUri) + expect(denialUrl.searchParams.get('error')).toBe('access_denied') + expect(denialUrl.searchParams.get('state')).toBe(`state-${testId}`) + expect(denialUrl.searchParams.get('iss')).toBe(`${baseUrl}/api/auth`) + expect(denialUrl.searchParams.has('code')).toBe(false) + expect( + await db + .select({ scopes: schema.oauthConsent.scopes }) + .from(schema.oauthConsent) + .where(eq(schema.oauthConsent.id, consentId)) + ).toEqual([{ scopes: grantedScopes }]) + + const blockedCodeExchange = await exchangeToken( + createFormRequest( + '/api/auth/oauth2/token', + new URLSearchParams({ + grant_type: 'authorization_code', + client_id: provider.SIM_CLI_CLIENT_ID, + code: withheldCode, + code_verifier: withheldVerifier, + redirect_uri: redirectUri, + }) + ) + ) + expect(blockedCodeExchange.status).toBe(400) + const blockedCodeBody = await blockedCodeExchange.json() + expect(blockedCodeBody).toMatchObject({ error: 'invalid_grant' }) + expect(blockedCodeBody).not.toHaveProperty('access_token') + expect(blockedCodeBody).not.toHaveProperty('refresh_token') + + const blockedRefresh = await exchangeToken( + createFormRequest( + '/api/auth/oauth2/token', + new URLSearchParams({ + grant_type: 'refresh_token', + client_id: provider.SIM_CLI_CLIENT_ID, + refresh_token: activeTokens.refresh_token, + }) + ) + ) + expect(blockedRefresh.status).toBe(400) + await expect(blockedRefresh.json()).resolves.toMatchObject({ error: 'invalid_grant' }) + expect( + await db + .select({ generation: schema.oauthTokenFamily.currentGeneration }) + .from(schema.oauthTokenFamily) + .where(eq(schema.oauthTokenFamily.userId, userId)) + ).toEqual([{ generation: 0 }]) + const revokedWhileWithheld = await revokeToken( + createFormRequest( + '/api/auth/oauth2/revoke', + new URLSearchParams({ + client_id: provider.SIM_CLI_CLIENT_ID, + token: activeTokens.refresh_token, + }) + ) + ) + expect(revokedWhileWithheld.status).toBe(200) + expect(await revokedWhileWithheld.text()).toBe('') + expect( + await db + .select({ id: schema.oauthTokenFamily.id }) + .from(schema.oauthTokenFamily) + .where(eq(schema.oauthTokenFamily.userId, userId)) + ).toHaveLength(0) + } finally { + if (issuedCodeHashes.length) { + await db + .delete(schema.verification) + .where(inArray(schema.verification.identifier, issuedCodeHashes)) + } + await db.delete(schema.verification).where(like(schema.verification.value, `%${userId}%`)) + await db.delete(schema.user).where(eq(schema.user.id, userId)) + await db + .delete(schema.subscription) + .where(eq(schema.subscription.referenceId, organizationId)) + await db.delete(schema.organization).where(eq(schema.organization.id, organizationId)) + await db + .delete(schema.rateLimitBucket) + .where( + inArray(schema.rateLimitBucket.key, [ + `route:oauth-provider-token:ip:${clientIp}`, + `route:oauth-provider-revoke:ip:${clientIp}`, + ]) + ) + requestUtilsMockFns.mockGetClientIp.mockReset() + requestUtilsMockFns.mockGetClientIp.mockReturnValue('127.0.0.1') + } + }, 60_000) +}) diff --git a/apps/sim/app/api/auth/oauth2/token/route.test.ts b/apps/sim/app/api/auth/oauth2/token/route.test.ts new file mode 100644 index 00000000000..2b72e219cf2 --- /dev/null +++ b/apps/sim/app/api/auth/oauth2/token/route.test.ts @@ -0,0 +1,358 @@ +/** + * @vitest-environment node + */ +import { resetEnvFlagsMock, setEnvFlags } from '@sim/testing' +import { NextRequest } from 'next/server' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + betterAuthPost: vi.fn(async () => new Response('delegated', { status: 201 })), + rateLimit: vi.fn(async () => null), + rotate: vi.fn(), + validateClient: vi.fn(), +})) + +vi.mock('better-auth/next-js', () => ({ + toNextJsHandler: () => ({ POST: mocks.betterAuthPost }), +})) +vi.mock('@/lib/auth', () => ({ auth: { handler: vi.fn() } })) +vi.mock('@/lib/auth/oauth-provider-adapter-guard', () => ({ + withOAuthProviderIssuanceCompensation: (work: () => Promise) => work(), +})) +vi.mock('@/lib/core/rate-limiter', () => ({ enforceIpRateLimit: mocks.rateLimit })) +vi.mock('@/lib/auth/oauth-token-family', () => ({ + rotateOAuthRefreshToken: mocks.rotate, + validateOAuthClientCredentials: mocks.validateClient, +})) + +import { POST } from '@/app/api/auth/oauth2/token/route' + +function tokenRequest(body: string) { + return new NextRequest('http://localhost/api/auth/oauth2/token', { + method: 'POST', + body, + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + }) +} + +afterAll(resetEnvFlagsMock) + +describe('OAuth token route', () => { + beforeEach(() => { + vi.clearAllMocks() + setEnvFlags({ isAuthDisabled: false }) + mocks.rotate.mockResolvedValue({ + success: true, + value: { + accessToken: 'sim_oat_next', + refreshToken: 'sim_ort_next', + expiresIn: 3600, + expiresAt: 2_000_000_000, + scope: 'offline_access api:read', + }, + }) + mocks.validateClient.mockResolvedValue({ success: true, value: undefined }) + }) + + it('delegates authorization-code exchange through an equivalent rebuilt request', async () => { + const response = await POST( + tokenRequest('grant_type=authorization_code&client_id=sim-cli&code=code') + ) + expect(response.status).toBe(201) + expect(mocks.betterAuthPost).toHaveBeenCalledOnce() + expect(mocks.validateClient).toHaveBeenCalledWith({ clientId: 'sim-cli', method: 'none' }) + const delegated = mocks.betterAuthPost.mock.calls[0]?.[0] + await expect(delegated.text()).resolves.toContain('grant_type=authorization_code') + expect(mocks.rateLimit).toHaveBeenCalledOnce() + }) + + it('rejects an authorization-code client using the wrong registered auth method', async () => { + mocks.validateClient.mockResolvedValue({ + success: false, + error: 'invalid_client', + description: 'Client authentication method does not match registration.', + }) + const basic = Buffer.from('client:secret').toString('base64') + const request = tokenRequest('grant_type=authorization_code&code=code') + request.headers.set('authorization', `Basic ${basic}`) + + const response = await POST(request) + + expect(response.status).toBe(401) + expect(response.headers.get('www-authenticate')).toContain('Basic') + expect(mocks.betterAuthPost).not.toHaveBeenCalled() + }) + + it('passes decoded Basic credentials through Better Auth body authentication', async () => { + const request = tokenRequest('grant_type=authorization_code&code=code') + request.headers.set('authorization', `basic ${Buffer.from('client:secret').toString('base64')}`) + + await POST(request) + + const delegated = mocks.betterAuthPost.mock.calls[0]?.[0] + expect(delegated.headers.has('authorization')).toBe(false) + const delegatedForm = new URLSearchParams(await delegated.text()) + expect(delegatedForm.get('client_id')).toBe('client') + expect(delegatedForm.get('client_secret')).toBe('secret') + }) + + it('normalizes delegated invalid-code and PKCE failures', async () => { + mocks.betterAuthPost.mockResolvedValueOnce( + Response.json({ error: 'invalid_grant', error_description: 'invalid code' }, { status: 401 }) + ) + const invalidCode = await POST( + tokenRequest('grant_type=authorization_code&client_id=sim-cli&code=bad') + ) + expect(invalidCode.status).toBe(400) + expect(invalidCode.headers.get('cache-control')).toBe('no-store') + expect(invalidCode.headers.get('pragma')).toBe('no-cache') + + mocks.betterAuthPost.mockResolvedValueOnce( + Response.json( + { error: 'invalid_request', error_description: 'code verification failed' }, + { status: 401 } + ) + ) + const invalidVerifier = await POST( + tokenRequest('grant_type=authorization_code&client_id=sim-cli&code=bad') + ) + expect(invalidVerifier.status).toBe(400) + await expect(invalidVerifier.json()).resolves.toMatchObject({ error: 'invalid_grant' }) + }) + + it.each([ + ['invalid_request', 'Either code_verifier or client_secret is required'], + ['invalid_request', 'PKCE is required for this client'], + ['invalid_request', 'redirect_uri mismatch'], + ['invalid_user', 'missing user, user may have been deleted'], + ['invalid_user', 'session no longer exists'], + ])('normalizes a consumed code failure from %s to invalid_grant', async (error, description) => { + mocks.betterAuthPost.mockResolvedValueOnce( + Response.json({ error, error_description: description }, { status: 401 }) + ) + + const response = await POST( + tokenRequest('grant_type=authorization_code&client_id=sim-cli&code=code') + ) + + expect(response.status).toBe(400) + expect(response.headers.has('www-authenticate')).toBe(false) + await expect(response.json()).resolves.toEqual({ + error: 'invalid_grant', + error_description: description, + }) + }) + + it.each(['short', `${'a'.repeat(42)}=`, 'a'.repeat(129)])( + 'rejects a malformed PKCE verifier before a code can be consumed', + async (codeVerifier) => { + const response = await POST( + tokenRequest( + `grant_type=authorization_code&client_id=sim-cli&code=code&code_verifier=${encodeURIComponent(codeVerifier)}` + ) + ) + + expect(response.status).toBe(400) + expect(response.headers.get('cache-control')).toBe('no-store') + await expect(response.json()).resolves.toEqual({ + error: 'invalid_grant', + error_description: 'Code verifier is invalid.', + }) + expect(mocks.validateClient).not.toHaveBeenCalled() + expect(mocks.betterAuthPost).not.toHaveBeenCalled() + } + ) + + it('does not challenge an authenticated client for a code bound to another client', async () => { + mocks.betterAuthPost.mockResolvedValueOnce( + Response.json( + { error: 'invalid_client', error_description: 'invalid client_id' }, + { status: 401, headers: { 'WWW-Authenticate': 'Basic realm="oauth2"' } } + ) + ) + const request = tokenRequest('grant_type=authorization_code&code=code') + request.headers.set('authorization', `Basic ${Buffer.from('client:secret').toString('base64')}`) + + const response = await POST(request) + + expect(response.status).toBe(400) + expect(response.headers.has('www-authenticate')).toBe(false) + await expect(response.json()).resolves.toMatchObject({ error: 'invalid_grant' }) + }) + + it('returns a bounded OAuth error when delegated issuance fails without JSON', async () => { + mocks.betterAuthPost.mockResolvedValueOnce(new Response(null, { status: 500 })) + + const response = await POST( + tokenRequest('grant_type=authorization_code&client_id=sim-cli&code=code') + ) + + expect(response.status).toBe(500) + await expect(response.json()).resolves.toEqual({ + error: 'server_error', + error_description: 'Token exchange failed.', + }) + }) + + it('normalizes Better Auth validation errors without exposing its internal shape', async () => { + mocks.betterAuthPost.mockResolvedValueOnce( + Response.json( + { + message: '[body.redirect_uri] Invalid URL; received not-a-url', + code: 'VALIDATION_ERROR', + }, + { status: 400 } + ) + ) + + const response = await POST( + tokenRequest( + 'grant_type=authorization_code&client_id=sim-cli&code=code&redirect_uri=not-a-url' + ) + ) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toEqual({ + error: 'invalid_request', + error_description: 'Token request is invalid.', + }) + }) + + it('redacts delegated JSON server failures to a bounded OAuth error', async () => { + mocks.betterAuthPost.mockResolvedValueOnce( + Response.json( + { message: 'internal database details', stack: 'secret stack' }, + { status: 503 } + ) + ) + + const response = await POST( + tokenRequest('grant_type=authorization_code&client_id=sim-cli&code=code') + ) + + expect(response.status).toBe(503) + await expect(response.json()).resolves.toEqual({ + error: 'server_error', + error_description: 'Token exchange failed.', + }) + }) + + it('rotates refresh tokens and preserves the Better Auth response shape', async () => { + const response = await POST( + tokenRequest( + 'grant_type=refresh_token&client_id=sim-cli&refresh_token=sim_ort_current&scope=api%3Aread' + ) + ) + expect(response.status).toBe(200) + expect(response.headers.get('cache-control')).toBe('no-store') + await expect(response.json()).resolves.toEqual({ + access_token: 'sim_oat_next', + expires_in: 3600, + expires_at: 2_000_000_000, + token_type: 'Bearer', + refresh_token: 'sim_ort_next', + scope: 'offline_access api:read', + }) + expect(mocks.rotate).toHaveBeenCalledWith({ + credentials: { clientId: 'sim-cli', method: 'none' }, + refreshToken: 'sim_ort_current', + requestedScopes: ['api:read'], + }) + }) + + it('renders protocol failures only after the rotation service returns', async () => { + mocks.rotate.mockResolvedValue({ + success: false, + error: 'invalid_grant', + description: 'Refresh token is invalid or has already been used.', + }) + const response = await POST( + tokenRequest('grant_type=refresh_token&client_id=sim-cli&refresh_token=sim_ort_old') + ) + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ error: 'invalid_grant' }) + }) + + it('distinguishes a missing grant type from an unsupported grant type', async () => { + const missing = await POST(tokenRequest('client_id=sim-cli')) + expect(missing.status).toBe(400) + await expect(missing.json()).resolves.toMatchObject({ error: 'invalid_request' }) + + const unsupported = await POST(tokenRequest('grant_type=client_credentials&client_id=sim-cli')) + expect(unsupported.status).toBe(400) + await expect(unsupported.json()).resolves.toMatchObject({ error: 'unsupported_grant_type' }) + expect(mocks.betterAuthPost).not.toHaveBeenCalled() + expect(mocks.rotate).not.toHaveBeenCalled() + }) + + it('reports a missing refresh token as an invalid request', async () => { + const response = await POST(tokenRequest('grant_type=refresh_token&client_id=sim-cli')) + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ error: 'invalid_request' }) + expect(mocks.rotate).not.toHaveBeenCalled() + }) + + it.each(['authorization_code', 'refresh_token'])( + 'rejects an unenforceable resource audience for %s grants', + async (grantType) => { + const response = await POST( + tokenRequest( + `grant_type=${grantType}&client_id=sim-cli&code=code&refresh_token=sim_ort_current&resource=https%3A%2F%2Fapi.example` + ) + ) + + expect(response.status).toBe(400) + await expect(response.json()).resolves.toMatchObject({ error: 'invalid_request' }) + expect(mocks.betterAuthPost).not.toHaveBeenCalled() + expect(mocks.rotate).not.toHaveBeenCalled() + } + ) + + it('applies rate admission before parsing and prevents caching a refusal', async () => { + mocks.rateLimit.mockResolvedValueOnce(new Response('limited', { status: 429 })) + const request = new NextRequest('http://localhost/api/auth/oauth2/token', { + method: 'POST', + body: '{}', + headers: { 'content-type': 'application/json' }, + }) + + const response = await POST(request) + + expect(response.status).toBe(429) + expect(response.headers.get('cache-control')).toBe('no-store') + expect(response.headers.get('pragma')).toBe('no-cache') + }) + + it.each([ + ['authorization code exchange', () => mocks.betterAuthPost.mockRejectedValueOnce(new Error())], + ['refresh rotation', () => mocks.rotate.mockRejectedValueOnce(new Error())], + ])('normalizes an unexpected %s failure', async (grant, fail) => { + fail() + const body = + grant === 'authorization code exchange' + ? 'grant_type=authorization_code&client_id=sim-cli&code=code' + : 'grant_type=refresh_token&client_id=sim-cli&refresh_token=sim_ort_current' + + const response = await POST(tokenRequest(body)) + + expect(response.status).toBe(500) + expect(response.headers.get('cache-control')).toBe('no-store') + expect(response.headers.get('pragma')).toBe('no-cache') + await expect(response.json()).resolves.toEqual({ + error: 'server_error', + error_description: 'Token endpoint failed.', + }) + }) + + it('requires authentication before token admission or issuance', async () => { + setEnvFlags({ isAuthDisabled: true }) + const response = await POST( + tokenRequest('grant_type=refresh_token&client_id=sim-cli&refresh_token=sim_ort_old') + ) + expect(response.status).toBe(404) + expect(response.headers.get('cache-control')).toBe('no-store') + expect(mocks.rotate).not.toHaveBeenCalled() + expect(mocks.rateLimit).not.toHaveBeenCalled() + expect(mocks.betterAuthPost).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/auth/oauth2/token/route.ts b/apps/sim/app/api/auth/oauth2/token/route.ts new file mode 100644 index 00000000000..95fe53d9727 --- /dev/null +++ b/apps/sim/app/api/auth/oauth2/token/route.ts @@ -0,0 +1,123 @@ +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { toNextJsHandler } from 'better-auth/next-js' +import { type NextRequest, NextResponse } from 'next/server' +import { auth } from '@/lib/auth' +import { + buildDelegatedOAuthRequest, + isValidOAuthCodeVerifier, + missingOAuthParameterResponse, + normalizeDelegatedOAuthTokenResponse, + oauthErrorResponse, + oauthProtocolErrorResponse, + parseOAuthFormRequest, + parseRequestedScopes, + unsupportedGrantResponse, +} from '@/lib/auth/oauth-protocol-request' +import { withOAuthProviderIssuanceCompensation } from '@/lib/auth/oauth-provider-adapter-guard' +import { + rotateOAuthRefreshToken, + validateOAuthClientCredentials, +} from '@/lib/auth/oauth-token-family' +import { isAuthDisabled } from '@/lib/core/config/env-flags' +import { enforceIpRateLimit, type TokenBucketConfig } from '@/lib/core/rate-limiter' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('OAuthTokenEndpoint') +const { POST: betterAuthPOST } = toNextJsHandler(auth.handler) + +const TOKEN_RATE_LIMIT: TokenBucketConfig = { + maxTokens: 20, + refillRate: 20, + refillIntervalMs: 60_000, +} + +/** + * Delegates authorization-code exchange to Better Auth and owns refresh + * rotation, whose per-login replay containment requires one PostgreSQL + * transaction that the provider does not expose as a configuration hook. + */ +export const POST = withRouteHandler(async (request: NextRequest) => { + if (isAuthDisabled) { + return NextResponse.json( + { error: 'OAuth provider is not enabled' }, + { status: 404, headers: { 'Cache-Control': 'no-store', Pragma: 'no-cache' } } + ) + } + + try { + const rateLimited = await enforceIpRateLimit('oauth-provider-token', request, TOKEN_RATE_LIMIT) + if (rateLimited) { + rateLimited.headers.set('Cache-Control', 'no-store') + rateLimited.headers.set('Pragma', 'no-cache') + return rateLimited + } + const parsed = await parseOAuthFormRequest(request) + if (!parsed.success) return parsed.response + const grantType = parsed.value.form.get('grant_type') + if (!grantType) return missingOAuthParameterResponse('grant_type') + if (grantType !== 'authorization_code' && grantType !== 'refresh_token') { + return unsupportedGrantResponse(grantType) + } + if (parsed.value.form.has('resource')) { + return oauthErrorResponse('invalid_request', 'The resource parameter is not supported.') + } + if (grantType === 'authorization_code') { + const codeVerifier = parsed.value.form.get('code_verifier') + if (codeVerifier !== null && !isValidOAuthCodeVerifier(codeVerifier)) { + return oauthErrorResponse('invalid_grant', 'Code verifier is invalid.') + } + if (!parsed.value.credentials) { + return oauthErrorResponse('invalid_client', 'Client authentication is required.') + } + const authenticated = await validateOAuthClientCredentials(parsed.value.credentials) + if (!authenticated.success) { + return oauthProtocolErrorResponse( + authenticated.error, + authenticated.description, + parsed.value.credentials.method + ) + } + const response = await withOAuthProviderIssuanceCompensation(() => + betterAuthPOST(buildDelegatedOAuthRequest(request, parsed.value)) + ) + return normalizeDelegatedOAuthTokenResponse(response, parsed.value.credentials.method) + } + + if (!parsed.value.credentials) { + return oauthErrorResponse('invalid_client', 'Client authentication is required.') + } + const refreshToken = parsed.value.form.get('refresh_token') + if (!refreshToken) return missingOAuthParameterResponse('refresh_token') + + const result = await rotateOAuthRefreshToken({ + credentials: parsed.value.credentials, + refreshToken, + requestedScopes: parseRequestedScopes(parsed.value.form), + }) + if (!result.success) { + return oauthProtocolErrorResponse( + result.error, + result.description, + parsed.value.credentials.method + ) + } + + return NextResponse.json( + { + access_token: result.value.accessToken, + expires_in: result.value.expiresIn, + expires_at: result.value.expiresAt, + token_type: 'Bearer', + refresh_token: result.value.refreshToken, + scope: result.value.scope, + }, + { headers: { 'Cache-Control': 'no-store', Pragma: 'no-cache' } } + ) + } catch (error) { + logger.error('OAuth token endpoint failed', { error: toError(error) }) + return oauthErrorResponse('server_error', 'Token endpoint failed.', 500) + } +}) diff --git a/apps/sim/app/api/auth/trello/callback/route.ts b/apps/sim/app/api/auth/trello/callback/route.ts index 2d45e1dca3b..bbb96651bbb 100644 --- a/apps/sim/app/api/auth/trello/callback/route.ts +++ b/apps/sim/app/api/auth/trello/callback/route.ts @@ -5,6 +5,7 @@ import { parseRequest } from '@/lib/api/server' import { getBaseUrl } from '@/lib/core/utils/urls' import { isSameOrigin } from '@/lib/core/utils/validation' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { APP_ENTRY_PATH } from '@/lib/navigation/paths' const logger = createLogger('TrelloCallback') @@ -48,7 +49,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const returnUrl = requestedReturnUrl && isSameOrigin(requestedReturnUrl) ? requestedReturnUrl - : `${baseUrl}/workspace` + : `${baseUrl}${APP_ENTRY_PATH}` const queryState = parsed.data.query.state const cookieState = request.cookies.get(TRELLO_STATE_COOKIE)?.value diff --git a/apps/sim/app/api/billing/portal/route.ts b/apps/sim/app/api/billing/portal/route.ts index 14b5d2bd4a8..7a11c742bbc 100644 --- a/apps/sim/app/api/billing/portal/route.ts +++ b/apps/sim/app/api/billing/portal/route.ts @@ -10,6 +10,7 @@ import { isOrganizationOwnerOrAdmin } from '@/lib/billing/core/organization' import { requireStripeClient } from '@/lib/billing/stripe-client' import { getBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { APP_ENTRY_PATH } from '@/lib/navigation/paths' const logger = createLogger('BillingPortal') @@ -28,7 +29,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } const context = parsedBody.data.context const organizationId = parsedBody.data.organizationId - const returnUrl = parsedBody.data.returnUrl || `${getBaseUrl()}/workspace?billing=updated` + const returnUrl = + parsedBody.data.returnUrl || `${getBaseUrl()}${APP_ENTRY_PATH}?billing=updated` const stripe = requireStripeClient() diff --git a/apps/sim/app/api/billing/update-cost/replay.integration.test.ts b/apps/sim/app/api/billing/update-cost/replay.integration.test.ts new file mode 100644 index 00000000000..043dd6bd802 --- /dev/null +++ b/apps/sim/app/api/billing/update-cost/replay.integration.test.ts @@ -0,0 +1,224 @@ +/** + * @vitest-environment node + */ +import { type ExecFileException, execFile } from 'node:child_process' +import { createServer } from 'node:http' +import { promisify } from 'node:util' +import { resetEnvFlagsMock, resetEnvMock, setEnv, setEnvFlags } from '@sim/testing' +import { NextRequest } from 'next/server' +import type { Sql } from 'postgres' +import { afterAll, describe, expect, it, vi } from 'vitest' + +const state = vi.hoisted(() => ({ + databaseUrl: process.env.BILLING_REPLAY_IT_DATABASE_URL, + copilotDirectory: process.env.BILLING_REPLAY_COPILOT_DIR, + client: null as Sql | null, + schema: `billing_callback_${process.pid}`, + temporaryFailures: 1, +})) + +vi.unmock('drizzle-orm') +vi.unmock('@sim/db/schema') +vi.mock('@sim/db', async () => { + const { drizzle } = await import('drizzle-orm/postgres-js') + const { default: postgres } = await import('postgres') + const client = postgres(state.databaseUrl ?? 'postgres://127.0.0.1:1/unused', { + max: 2, + connection: { search_path: state.schema }, + onnotice: () => {}, + }) + state.client = client + const db = drizzle(client) + return { db, dbReplica: db } +}) + +/** Subscription and payment-provider fixtures; callback, ledger and replay code are real. */ +vi.mock('@/lib/billing/core/subscription', () => { + const subscription = async (userId: string) => { + if (userId === 'billing-replay-transient' && state.temporaryFailures-- > 0) { + throw new Error('Temporary subscription lookup failure') + } + return { + id: 'billing-replay-subscription', + referenceId: userId, + plan: 'pro', + status: 'active', + periodStart: new Date('2025-02-01T00:00:00.000Z'), + periodEnd: new Date('2025-03-01T00:00:00.000Z'), + } + } + return { + getHighestPrioritySubscription: subscription, + getHighestPriorityPersonalSubscription: subscription, + getOrganizationSubscriptionUsable: vi.fn(), + } +}) +vi.mock('@/lib/billing/core/plan', () => ({ + getHighestPrioritySubscription: vi.fn(), + getHighestPriorityPersonalSubscription: vi.fn(), +})) +vi.mock('@/lib/billing/core/access', () => ({ + getEffectiveBillingStatus: async () => ({ billingBlocked: false }), + isOrganizationBillingBlocked: async () => false, +})) +vi.mock('@/lib/billing/core/billing', () => ({ + calculateSubscriptionOverage: async () => 0, + computeOrgOverageAmount: vi.fn(), + getOrganizationSubscription: vi.fn(), +})) +vi.mock('@/lib/billing/cycle-close', () => ({ isSubscriptionCycleCloseCurrent: async () => true })) +vi.mock('@/lib/billing/plan-helpers', () => ({ isEnterprise: () => false, isFree: () => false })) +vi.mock('@/lib/billing/subscriptions/utils', () => ({ + hasUsableSubscriptionAccess: () => true, + isOrgScopedSubscription: () => false, +})) +vi.mock('@/lib/billing/calculations/usage-monitor', () => ({ + checkBillingBlocked: vi.fn(), + checkBillingEntityBlocked: vi.fn(), + checkOrganizationMemberUsageLimit: vi.fn(), + checkUsageStatus: vi.fn(), +})) +vi.mock('@/lib/billing/webhooks/outbox-handlers', () => ({ + OUTBOX_EVENT_TYPES: { STRIPE_THRESHOLD_OVERAGE_INVOICE: 'stripe.threshold-overage-invoice' }, +})) +vi.mock('@/lib/core/outbox/service', () => ({ enqueueOutboxEvent: vi.fn() })) +vi.mock('@sim/audit', () => ({ AuditAction: {}, AuditResourceType: {}, recordAudit: vi.fn() })) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() })) +vi.mock('@/lib/copilot/request/otel', () => ({ + withIncomingGoSpan: ( + _headers: unknown, + _name: unknown, + _attrs: unknown, + run: (span: { setAttribute: () => void; setAttributes: () => void }) => unknown + ) => run({ setAttribute: vi.fn(), setAttributes: vi.fn() }), +})) + +import { POST } from '@/app/api/billing/update-cost/route' + +const run = promisify(execFile) + +afterAll(async () => { + await state.client?.end() + resetEnvMock() + resetEnvFlagsMock() +}) + +/** + * Requires an isolated localhost PostgreSQL database and the Copilot checkout. + * Runs the actual Go client/reconciler/repository through HTTP into this route, + * with real header validation, cumulative ledger SQL and period classification. + */ +describe.skipIf(!state.databaseUrl || !state.copilotDirectory)( + 'cross-service billing replay', + () => { + it('quarantines expired charges durably and recovers temporary failures without double billing', async () => { + const databaseUrl = new URL(state.databaseUrl as string) + expect(['127.0.0.1', 'localhost']).toContain(databaseUrl.hostname) + const client = state.client + if (!client) throw new Error('Test database client was not initialized') + setEnv({ INTERNAL_API_SECRET: 'billing-replay-local-secret' }) + setEnvFlags({ isBillingEnabled: true, isHosted: true }) + await client.unsafe(`CREATE SCHEMA "${state.schema}"`) + const statuses: number[] = [] + const server = createServer(async (request, response) => { + try { + const chunks: Buffer[] = [] + let bytes = 0 + for await (const chunk of request) { + const buffer = Buffer.from(chunk) + bytes += buffer.length + if (bytes > 16384) throw new Error('Test request exceeds the callback fixture limit') + chunks.push(buffer) + } + const headers = new Headers() + for (const [key, value] of Object.entries(request.headers)) { + if (typeof value === 'string') headers.set(key, value) + } + const result = await POST( + new NextRequest(`http://127.0.0.1${request.url}`, { + method: 'POST', + headers, + body: Buffer.concat(chunks).toString(), + }) + ) + statuses.push(result.status) + response.writeHead(result.status, Object.fromEntries(result.headers)) + response.end(await result.text()) + } catch (error) { + response.writeHead(500) + response.end(String(error)) + } + }) + try { + await client.unsafe(`CREATE TABLE "user" (id text PRIMARY KEY); + INSERT INTO "user" (id) VALUES ('billing-replay-actor'), ('billing-replay-transient'); + CREATE TABLE usage_log ( + id text PRIMARY KEY, user_id text NOT NULL, category text NOT NULL, source text NOT NULL, + description text NOT NULL, metadata jsonb, cost numeric NOT NULL, event_key text, + billing_entity_type text, billing_entity_id text, billing_period_start timestamp, + billing_period_end timestamp, workspace_id text, workflow_id text, execution_id text, + created_at timestamp NOT NULL DEFAULT now(), + CONSTRAINT usage_log_user_id_user_id_fk FOREIGN KEY (user_id) REFERENCES "user"(id) + ); CREATE UNIQUE INDEX usage_log_event_key_unique ON usage_log(event_key) WHERE event_key IS NOT NULL`) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (!address || typeof address === 'string') throw new Error('Expected HTTP server port') + const result = await run( + 'go', + [ + 'test', + './internal/analytics', + '-run', + '^TestBillingReplayCrossService$', + '-count=1', + '-v', + ], + { + cwd: state.copilotDirectory, + env: { + ...process.env, + INTERNAL_API_SECRET: 'billing-replay-local-secret', + BILLING_REPLAY_SIM_URL: `http://127.0.0.1:${address.port}`, + BILLING_REPLAY_IT_DATABASE_URL: state.databaseUrl, + }, + timeout: 60_000, + maxBuffer: 1024 * 1024, + } + ).catch((error: ExecFileException & { stdout?: string; stderr?: string }) => { + throw new Error([error.message, error.stdout, error.stderr].filter(Boolean).join('\n'), { + cause: error, + }) + }) + expect(result.stdout).toContain('--- PASS: TestBillingReplayCrossService') + expect(statuses.filter((status) => status === 503)).toHaveLength(1) + expect(statuses.filter((status) => status === 409)).toHaveLength(10) + const rows = + await client`SELECT user_id, cost, billing_period_start::text AS period_start, billing_period_end::text AS period_end FROM usage_log ORDER BY user_id` + expect(rows).toHaveLength(4) + for (const row of rows) { + expect(Number(row.cost)).toBe(1.25) + expect(row.period_start).toBe( + row.user_id === 'billing-replay-transient' + ? '2025-02-01 00:00:00' + : '2025-01-01 00:00:00' + ) + expect(row.period_end).toBe( + row.user_id === 'billing-replay-transient' + ? '2025-03-01 00:00:00' + : '2025-02-01 00:00:00' + ) + } + } finally { + try { + if (server.listening) { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())) + ) + } + } finally { + await client.unsafe(`DROP SCHEMA "${state.schema}" CASCADE`) + } + } + }, 90_000) + } +) diff --git a/apps/sim/app/api/billing/update-cost/route.test.ts b/apps/sim/app/api/billing/update-cost/route.test.ts index cf9686740d4..2b38862261f 100644 --- a/apps/sim/app/api/billing/update-cost/route.test.ts +++ b/apps/sim/app/api/billing/update-cost/route.test.ts @@ -27,7 +27,9 @@ const { MockCumulativeUsageContextMismatchError: class extends Error {}, MockThresholdSettlementError: class extends Error { readonly code: string - readonly retryable = true + get retryable() { + return this.code !== 'billing_period_elapsed' + } constructor(code: string) { super('Billing settlement temporarily unavailable') @@ -66,7 +68,7 @@ vi.mock('@/lib/billing/core/billing-attribution', () => ({ }, COPILOT_BILLING_PROTOCOL_HEADER: 'x-sim-billing-protocol', requireAccountBillingDecisionHeader: mockRequireAccountBillingDecisionHeader, - requireBillingAttributionHeader: mockRequireBillingAttributionHeader, + requireBillingCallbackAttribution: mockRequireBillingAttributionHeader, resolveLegacyV0BillingAttribution: mockResolveLegacyV0BillingAttribution, toBillingContext: mockToBillingContext, })) @@ -301,6 +303,33 @@ describe('POST /api/billing/update-cost — workspaceId attribution', () => { expect(mockRecordCumulativeUsage).not.toHaveBeenCalled() }) + it('settles an organization charge from its immutable envelope with no workspace ID', async () => { + const orgAttribution = { ...ATTRIBUTION, workspaceId: null } + mockRequireBillingAttributionHeader.mockReturnValueOnce(orgAttribution) + const id = '00000000-0000-4000-8000-000000000001' + const response = await POST( + createMockRequest( + 'POST', + { ...SELF_HOSTED_WORKSPACELESS_UPDATE_COST_BODY, idempotencyKey: id }, + { + 'x-api-key': 'internal', + 'x-sim-billing-protocol': 'attribution-v1', + 'x-sim-billing-request-id': id, + 'x-sim-billing-attribution': 'serialized-org-attribution', + } + ) + ) + expect(response.status).toBe(200) + expect(mockRecordCumulativeUsage).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'user-1', + workspaceId: undefined, + billingEntity: { type: 'organization', id: 'org-1' }, + }) + ) + expect(mockResolveLegacyV0BillingAttribution).not.toHaveBeenCalled() + }) + it('does not let markerless legacy traffic fall through to a modern attribution envelope', async () => { const res = await POST( createMockRequest('POST', SELF_HOSTED_UPDATE_COST_BODY, { @@ -587,6 +616,110 @@ describe('POST /api/billing/update-cost — workspaceId attribution', () => { ) }) + it.each(['legacy-v0', 'attribution-v1', 'direct-v1'])( + 'returns a distinct non-retryable conflict for an elapsed %s period and preserves usage attribution', + async (protocol) => { + const billingRequestId = '0190c03f-9f7d-4b79-8b58-e7f779fd29e1' + const direct = protocol === 'direct-v1' + setEnvFlags({ isBillingEnabled: true, isHosted: true }) + mockCheckAndBillPayerOverageThreshold.mockRejectedValue( + new MockThresholdSettlementError('billing_period_elapsed') + ) + mockRecordCumulativeUsage + .mockResolvedValueOnce({ billed: true, delta: 0.5, total: 0.5 }) + .mockResolvedValueOnce({ billed: false, delta: 0, total: 0.5 }) + + for (let attempt = 0; attempt < 2; attempt++) { + const res = await POST( + createMockRequest( + 'POST', + { + userId: 'user-1', + cost: 0.5, + model: 'claude-opus-4.8', + source: 'copilot', + idempotencyKey: billingRequestId, + ...(direct ? {} : { workspaceId: 'ws-1' }), + }, + { + 'x-api-key': 'internal', + 'x-sim-billing-protocol': protocol, + ...(protocol === 'legacy-v0' ? {} : { 'x-sim-billing-request-id': billingRequestId }), + ...(direct + ? { 'x-sim-billing-account-decision': 'serialized-account-decision' } + : { 'x-sim-billing-attribution': 'serialized-attribution' }), + } + ) + ) + expect(res.status).toBe(409) + expect(res.headers.get('retry-after')).toBeNull() + await expect(res.json()).resolves.toMatchObject({ + success: false, + code: 'BILLING_PERIOD_ELAPSED', + error: 'Billing period has elapsed; reconciliation required', + retryable: false, + }) + } + expect(mockRecordCumulativeUsage).toHaveBeenCalledTimes(2) + expect(mockRecordCumulativeUsage).toHaveBeenLastCalledWith( + expect.objectContaining({ + eventKey: `update-cost:${billingRequestId}`, + billingPeriod: { + start: new Date('2026-07-01T00:00:00.000Z'), + end: new Date('2026-08-01T00:00:00.000Z'), + ...(direct ? { source: 'reporting' } : {}), + }, + }) + ) + } + ) + + it.each([ + ['23503', 'usage_log_user_id_user_id_fk', false, 409], + ['23503', 'usage_log_workspace_id_workspace_id_fk', false, 500], + ['40001', 'usage_log_user_id_user_id_fk', false, 500], + ['23503', 'usage_log_user_id_user_id_fk', true, 500], + ])( + 'classifies the exact missing-user constraint safely (%s, %s, markerless=%s)', + async (code, constraint, markerless, status) => { + mockRecordCumulativeUsage.mockRejectedValueOnce( + new Error('Insert failed', { + cause: { code, constraint_name: constraint }, + }) + ) + const res = await POST( + createMockRequest('POST', SELF_HOSTED_UPDATE_COST_BODY, { + 'x-api-key': 'internal', + ...(markerless + ? {} + : { + 'x-sim-billing-protocol': 'legacy-v0', + 'x-sim-billing-attribution': 'serialized-attribution', + }), + }) + ) + expect(res.status).toBe(status) + expect(mockCheckAndBillPayerOverageThreshold).not.toHaveBeenCalled() + expect(mockCheckAndBillOverageThreshold).not.toHaveBeenCalled() + if (status === 409) { + await expect(res.json()).resolves.toMatchObject({ + code: 'BILLING_USER_NOT_FOUND', + retryable: false, + }) + } + } + ) + + it('does not expose elapsed-period 409 to markerless clients that treat all conflicts as success', async () => { + mockCheckAndBillPayerOverageThreshold.mockRejectedValueOnce( + new MockThresholdSettlementError('billing_period_elapsed') + ) + const res = await POST( + createMockRequest('POST', SELF_HOSTED_UPDATE_COST_BODY, { 'x-api-key': 'internal' }) + ) + expect(res.status).toBe(503) + }) + it('returns a stable retryable 503 when modern threshold settlement fails', async () => { const billingRequestId = '0190c03f-9f7d-4b79-8b58-e7f779fd29e1' mockCheckAndBillPayerOverageThreshold.mockRejectedValueOnce( diff --git a/apps/sim/app/api/billing/update-cost/route.ts b/apps/sim/app/api/billing/update-cost/route.ts index 22623c17500..f9537851d57 100644 --- a/apps/sim/app/api/billing/update-cost/route.ts +++ b/apps/sim/app/api/billing/update-cost/route.ts @@ -14,7 +14,7 @@ import { COPILOT_BILLING_PROTOCOL_HEADER, type CopilotBillingProtocol, requireAccountBillingDecisionHeader, - requireBillingAttributionHeader, + requireBillingCallbackAttribution, resolveLegacyV0BillingAttribution, toBillingContext, } from '@/lib/billing/core/billing-attribution' @@ -156,8 +156,17 @@ async function updateCostInner(req: NextRequest, span: Span): Promise ({ mockCheckInternalApiKey: vi.fn(), mockCheckAttributedUsageLimits: vi.fn(), @@ -41,6 +43,7 @@ const { mockSerializeBillingAttributionHeader: vi.fn(), mockGetUserEntityPermissions: vi.fn(), mockGetWorkspaceBillingSettings: vi.fn(), + mockAuthorizeOrganizationChat: vi.fn(), })) const ATTRIBUTION = { @@ -117,6 +120,10 @@ vi.mock('@/lib/billing/core/usage-log', () => ({ deriveBillingContext: mockDeriveBillingContext, })) +vi.mock('@/lib/copilot/chat/organization-chats', () => ({ + authorizeOrganizationChatDelegation: { execute: mockAuthorizeOrganizationChat }, +})) + vi.mock('@/lib/copilot/request/http', () => ({ checkInternalApiKey: mockCheckInternalApiKey, })) @@ -312,6 +319,73 @@ describe('POST /api/copilot/api-keys/validate billing protocols', () => { expect(mockCheckServerSideUsageLimits).not.toHaveBeenCalled() }) + it('requires the current actor and canonical private chat for organization admission', async () => { + const orgAttribution = { ...ATTRIBUTION, workspaceId: null } + mockRequireBillingAttributionHeader.mockReturnValueOnce(orgAttribution) + const response = await POST( + createMockRequest( + 'POST', + { userId: 'user-1', organizationId: 'org-1', chatId: 'chat-1' }, + { + 'x-api-key': 'internal', + 'x-sim-billing-protocol': 'attribution-v1', + 'x-sim-billing-request-id': '00000000-0000-4000-8000-000000000001', + 'x-sim-billing-attribution': 'serialized-attribution', + } + ) + ) + expect(response.status).toBe(200) + expect(mockAuthorizeOrganizationChat).toHaveBeenCalledWith({ + principal: expect.objectContaining({ + kind: 'organization_delegated', + subjectUserId: 'user-1', + organizationId: 'org-1', + resourceScope: { chatId: 'chat-1' }, + }), + }) + expect(mockRequireBillingAttributionHeader).toHaveBeenCalledWith(expect.anything(), { + actorUserId: 'user-1', + organizationId: 'org-1', + }) + expect(mockCheckAttributedUsageLimits).toHaveBeenCalledWith(orgAttribution) + }) + + it('denies removed members before billing admission', async () => { + mockAuthorizeOrganizationChat.mockRejectedValueOnce( + new OrchestrationError('not_found', 'Conversation not found') + ) + const response = await POST( + createMockRequest( + 'POST', + { userId: 'user-1', organizationId: 'org-1', chatId: 'chat-1' }, + { 'x-api-key': 'internal', 'x-sim-billing-protocol': 'attribution-v1' } + ) + ) + expect(response.status).toBe(403) + expect(mockCheckAttributedUsageLimits).not.toHaveBeenCalled() + }) + + it('rejects markerless organization admission rather than settling it as a personal account', async () => { + const response = await POST( + request({ userId: 'user-1', organizationId: 'org-1', chatId: 'chat-1' }) + ) + expect(response.status).toBe(400) + expect(mockAuthorizeOrganizationChat).not.toHaveBeenCalled() + expect(mockCheckServerSideUsageLimits).not.toHaveBeenCalled() + }) + + it('rejects an organization request missing its private chat', async () => { + const response = await POST( + createMockRequest( + 'POST', + { userId: 'user-1', organizationId: 'org-1' }, + { 'x-api-key': 'internal', 'x-sim-billing-protocol': 'attribution-v1' } + ) + ) + expect(response.status).toBe(400) + expect(mockCheckAttributedUsageLimits).not.toHaveBeenCalled() + }) + it('uses the exact frozen attribution for attributed-v1 admission', async () => { const res = await POST( request( diff --git a/apps/sim/app/api/copilot/api-keys/validate/route.ts b/apps/sim/app/api/copilot/api-keys/validate/route.ts index ae9e01f4782..970220f9099 100644 --- a/apps/sim/app/api/copilot/api-keys/validate/route.ts +++ b/apps/sim/app/api/copilot/api-keys/validate/route.ts @@ -1,6 +1,7 @@ import { db } from '@sim/db' import { user } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { generateId } from '@sim/utils/id' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { validateCopilotApiKeyContract } from '@/lib/api/contracts/copilot' @@ -13,12 +14,18 @@ import { requireBillingAttributionHeader, requireBillingRequestIdHeader, resolveLegacyV0BillingAttribution, + resolveOrganizationBillingAttribution, serializeAccountBillingDecisionHeader, serializeBillingAttributionHeader, } from '@/lib/billing/core/billing-attribution' import { getHighestPrioritySubscription } from '@/lib/billing/core/plan' import { isEnterprisePlan } from '@/lib/billing/core/subscription' import { deriveBillingContext } from '@/lib/billing/core/usage-log' +import { + COPILOT_APPLICATION_DELEGATION_TTL_MS, + createTrustedOrganizationCopilotPrincipal, +} from '@/lib/copilot/auth/application-delegation' +import { authorizeOrganizationChatDelegation } from '@/lib/copilot/chat/organization-chats' import { BILLING_ACCOUNT_DECISION_HEADER, BILLING_ATTRIBUTION_HEADER, @@ -33,6 +40,7 @@ import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' import { checkInternalApiKey } from '@/lib/copilot/request/http' import { withIncomingGoSpan } from '@/lib/copilot/request/otel' import { isHosted } from '@/lib/core/config/env-flags' +import { asOrchestrationError } from '@/lib/core/orchestration/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CopilotApiKeysValidate') @@ -51,7 +59,7 @@ type AdmissionBillingDecision = userId: string } | { - kind: 'legacy-workspace' + kind: 'legacy-scoped' attribution: BillingAttributionSnapshot includeAttribution: boolean } @@ -74,21 +82,38 @@ async function resolveAdmissionBillingDecision( req: NextRequest, protocol: CopilotBillingProtocol | undefined, actorUserId: string, - workspaceId: string | undefined + workspaceId: string | undefined, + organizationId: string | undefined, + chatId: string | undefined ): Promise { const hasBillingRequestId = Boolean(req.headers.get(BILLING_REQUEST_ID_HEADER)) const hasBillingAttribution = Boolean(req.headers.get(BILLING_ATTRIBUTION_HEADER)) const hasBillingAccountDecision = Boolean(req.headers.get(BILLING_ACCOUNT_DECISION_HEADER)) + if (organizationId && protocol === undefined) return invalidBillingProtocolResponse() + if (organizationId && protocol !== COPILOT_BILLING_PROTOCOL.direct) { + if (!chatId) return invalidBillingProtocolResponse() + const principal = createTrustedOrganizationCopilotPrincipal( + { + userId: actorUserId, + organizationId, + chatId, + delegationId: req.headers.get(BILLING_REQUEST_ID_HEADER) ?? generateId(), + }, + { audience: 'sim:copilot-billing', ttlMs: COPILOT_APPLICATION_DELEGATION_TTL_MS } + ) + await authorizeOrganizationChatDelegation.execute({ principal }) + } + if (protocol === COPILOT_BILLING_PROTOCOL.attributed) { - if (!workspaceId || hasBillingAccountDecision) { + if ((!workspaceId && !organizationId) || hasBillingAccountDecision) { return invalidBillingProtocolResponse() } try { requireBillingRequestIdHeader(req.headers) const attribution = requireBillingAttributionHeader(req.headers, { actorUserId, - workspaceId, + ...(organizationId ? { organizationId } : { workspaceId }), }) return { kind: 'attributed', attribution } } catch { @@ -124,10 +149,18 @@ async function resolveAdmissionBillingDecision( if (hasBillingRequestId || hasBillingAttribution || hasBillingAccountDecision) { return invalidBillingProtocolResponse() } - if (protocol === COPILOT_BILLING_PROTOCOL.legacy && !workspaceId) { + if (protocol === COPILOT_BILLING_PROTOCOL.legacy && !workspaceId && !organizationId) { return invalidBillingProtocolResponse() } + if (organizationId) { + return { + kind: 'legacy-scoped', + attribution: await resolveOrganizationBillingAttribution({ actorUserId, organizationId }), + includeAttribution: true, + } + } + if (workspaceId) { const attribution = await resolveLegacyV0BillingAttribution({ actorUserId, @@ -135,7 +168,7 @@ async function resolveAdmissionBillingDecision( }) if (attribution) { return { - kind: 'legacy-workspace', + kind: 'legacy-scoped', attribution, includeAttribution: protocol === COPILOT_BILLING_PROTOCOL.legacy, } @@ -152,7 +185,7 @@ async function checkAdmissionUsage(admission: AdmissionBillingDecision): Promise scope: string accountBillingDecision?: AccountBillingDecision }> { - if (admission.kind === 'attributed' || admission.kind === 'legacy-workspace') { + if (admission.kind === 'attributed' || admission.kind === 'legacy-scoped') { const usage = await checkAttributedUsageLimits(admission.attribution) const enforcedUsage = usage.scope === 'member' && usage.memberUsage ? usage.memberUsage : usage.payerUsage @@ -254,7 +287,7 @@ export const POST = withRouteHandler((req: NextRequest) => ) if (!parsed.success) return parsed.response - const { userId, workspaceId } = parsed.data.body + const { userId, workspaceId, organizationId, chatId } = parsed.data.body const protocol = parsed.data.headers?.[COPILOT_BILLING_PROTOCOL_HEADER] span.setAttribute(TraceAttr.UserId, userId) @@ -267,7 +300,14 @@ export const POST = withRouteHandler((req: NextRequest) => } logger.info('[API VALIDATION] Validating usage limit', { userId }) - const admission = await resolveAdmissionBillingDecision(req, protocol, userId, workspaceId) + const admission = await resolveAdmissionBillingDecision( + req, + protocol, + userId, + workspaceId, + organizationId, + chatId + ) if (admission instanceof NextResponse) { span.setAttribute(TraceAttr.CopilotValidateOutcome, CopilotValidateOutcome.InvalidBody) span.setAttribute(TraceAttr.HttpStatusCode, admission.status) @@ -289,9 +329,9 @@ export const POST = withRouteHandler((req: NextRequest) => scope: usage.scope, billingProtocol: protocol ?? COPILOT_BILLING_PROTOCOL.legacy, billingResolution: - admission.kind === 'legacy-workspace' ? 'mutable-request-time' : 'immutable-or-account', + admission.kind === 'legacy-scoped' ? 'mutable-request-time' : 'immutable-or-account', billingPayer: - admission.kind === 'attributed' || admission.kind === 'legacy-workspace' + admission.kind === 'attributed' || admission.kind === 'legacy-scoped' ? admission.attribution.billingEntity : (usage.accountBillingDecision?.billingEntity ?? { type: 'account', id: userId }), }) @@ -322,7 +362,7 @@ export const POST = withRouteHandler((req: NextRequest) => responseHeaders[BILLING_ACCOUNT_DECISION_HEADER] = serializeAccountBillingDecisionHeader( usage.accountBillingDecision ) - } else if (admission.kind === 'legacy-workspace' && admission.includeAttribution) { + } else if (admission.kind === 'legacy-scoped' && admission.includeAttribution) { responseHeaders[BILLING_ATTRIBUTION_HEADER] = serializeBillingAttributionHeader( admission.attribution ) @@ -334,6 +374,9 @@ export const POST = withRouteHandler((req: NextRequest) => span.setAttribute(TraceAttr.HttpStatusCode, 200) return NextResponse.json({ isEnterprise }, { status: 200, headers: responseHeaders }) } catch (error) { + const code = asOrchestrationError(error)?.code + if (code === 'not_found' || code === 'forbidden') + return NextResponse.json({ error: 'Conversation access denied' }, { status: 403 }) logger.error('Error validating usage limit', { error }) span.setAttribute(TraceAttr.CopilotValidateOutcome, CopilotValidateOutcome.InternalError) span.setAttribute(TraceAttr.HttpStatusCode, 500) diff --git a/apps/sim/app/api/copilot/chat/abort/route.test.ts b/apps/sim/app/api/copilot/chat/abort/route.test.ts index f3655d0f825..dcd1c74dd33 100644 --- a/apps/sim/app/api/copilot/chat/abort/route.test.ts +++ b/apps/sim/app/api/copilot/chat/abort/route.test.ts @@ -5,6 +5,7 @@ import { createMockRequest } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' const { + mockGetAccessibleChat, mockAbortActiveStream, mockAuthenticate, mockGetLatestRunForStream, @@ -16,6 +17,7 @@ const { const order: string[] = [] return { order, + mockGetAccessibleChat: vi.fn(), mockAbortActiveStream: vi.fn(async () => { order.push('abortActiveStream') return true @@ -30,6 +32,10 @@ const { } }) +vi.mock('@/lib/copilot/chat/lifecycle', () => ({ + getAccessibleCopilotChatAuth: mockGetAccessibleChat, +})) + vi.mock('@/lib/copilot/request/http', () => ({ authenticateCopilotRequestSessionOnly: mockAuthenticate, })) @@ -55,6 +61,7 @@ describe('POST /api/copilot/chat/abort', () => { beforeEach(() => { vi.clearAllMocks() order.length = 0 + mockGetAccessibleChat.mockResolvedValue({ id: 'chat-1' }) mockAuthenticate.mockResolvedValue({ userId: 'user-1', isAuthenticated: true }) mockGetLatestRunForStream.mockResolvedValue({ chatId: 'chat-1', workspaceId: 'workspace-1' }) mockWaitForPendingChatStream.mockResolvedValue(true) @@ -93,6 +100,21 @@ describe('POST /api/copilot/chat/abort', () => { expect(mockReleasePendingChatStream).toHaveBeenCalledWith('chat-1', 'stream-1') }) + it('refuses an inaccessible organization chat before changing stream state', async () => { + mockGetAccessibleChat.mockResolvedValueOnce(null) + const response = await POST(abortRequest()) + expect(response.status).toBe(404) + expect(mockRequestExplicitStreamAbort).not.toHaveBeenCalled() + expect(mockAbortActiveStream).not.toHaveBeenCalled() + }) + + it('refuses a chat ID that does not belong to the authenticated run', async () => { + mockGetLatestRunForStream.mockResolvedValueOnce({ chatId: 'different-chat' }) + const response = await POST(abortRequest()) + expect(response.status).toBe(404) + expect(mockAbortActiveStream).not.toHaveBeenCalled() + }) + it('rejects an unauthenticated caller without touching either abort path', async () => { mockAuthenticate.mockResolvedValue({ userId: undefined, isAuthenticated: false }) diff --git a/apps/sim/app/api/copilot/chat/abort/route.ts b/apps/sim/app/api/copilot/chat/abort/route.ts index bd90ccf1083..8aea11d6439 100644 --- a/apps/sim/app/api/copilot/chat/abort/route.ts +++ b/apps/sim/app/api/copilot/chat/abort/route.ts @@ -4,6 +4,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { copilotChatAbortBodySchema } from '@/lib/api/contracts/copilot' import { validationErrorResponse } from '@/lib/api/server' import { getLatestRunForStream } from '@/lib/copilot/async-runs/repository' +import { getAccessibleCopilotChatAuth } from '@/lib/copilot/chat/lifecycle' import { CopilotAbortOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1' @@ -29,8 +30,11 @@ export const POST = withRouteHandler((request: NextRequest) => TraceSpan.CopilotChatAbortStream, undefined, async (rootSpan) => { - const { userId: authenticatedUserId, isAuthenticated } = - await authenticateCopilotRequestSessionOnly() + const { + userId: authenticatedUserId, + isAuthenticated, + principal, + } = await authenticateCopilotRequestSessionOnly() if (!isAuthenticated || !authenticatedUserId) { rootSpan.setAttribute(TraceAttr.CopilotAbortOutcome, CopilotAbortOutcome.Unauthorized) @@ -67,6 +71,15 @@ export const POST = withRouteHandler((request: NextRequest) => }) return null }) + if (!run || (chatId && chatId !== run.chatId)) { + return NextResponse.json({ error: 'Stream not found' }, { status: 404 }) + } + const chat = run.chatId + ? await getAccessibleCopilotChatAuth(run.chatId, authenticatedUserId, { principal }) + : null + if (run.chatId && !chat) { + return NextResponse.json({ error: 'Stream not found' }, { status: 404 }) + } if (!chatId && run?.chatId) { chatId = run.chatId } @@ -98,6 +111,7 @@ export const POST = withRouteHandler((request: NextRequest) => userId: authenticatedUserId, chatId, workspaceId, + ...(chat?.organizationId ? { organizationId: chat.organizationId } : {}), timeoutMs: GO_EXPLICIT_ABORT_TIMEOUT_MS, }) goAbortOk = true diff --git a/apps/sim/app/api/copilot/chat/route.ts b/apps/sim/app/api/copilot/chat/route.ts index 1b020709711..4a5c9ae2e73 100644 --- a/apps/sim/app/api/copilot/chat/route.ts +++ b/apps/sim/app/api/copilot/chat/route.ts @@ -1,10 +1,10 @@ import type { NextRequest } from 'next/server' import { copilotChatGetContract } from '@/lib/api/contracts/copilot' import { parseRequest } from '@/lib/api/server' -import { handleUnifiedChatPost, maxDuration } from '@/lib/copilot/chat/post' +import { handleUnifiedChatPost } from '@/lib/copilot/chat/post' import { GET as getChat } from '@/app/api/copilot/chat/queries' -export { maxDuration } +export const maxDuration = 3600 export const POST = handleUnifiedChatPost diff --git a/apps/sim/app/api/copilot/chat/stop/route.test.ts b/apps/sim/app/api/copilot/chat/stop/route.test.ts index 7c35ca8d355..ccee5a1bde7 100644 --- a/apps/sim/app/api/copilot/chat/stop/route.test.ts +++ b/apps/sim/app/api/copilot/chat/stop/route.test.ts @@ -5,9 +5,15 @@ import { authMockFns, dbChainMockFns, resetDbChainMock } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockAppendCopilotChatMessages, mockPublishStatusChanged } = vi.hoisted(() => ({ - mockAppendCopilotChatMessages: vi.fn(), - mockPublishStatusChanged: vi.fn(), +const { mockAppendCopilotChatMessages, mockPublishStatusChanged, mockGetAccessibleChat } = + vi.hoisted(() => ({ + mockGetAccessibleChat: vi.fn(), + mockAppendCopilotChatMessages: vi.fn(), + mockPublishStatusChanged: vi.fn(), + })) + +vi.mock('@/lib/copilot/chat/lifecycle', () => ({ + getAccessibleCopilotChatAuth: mockGetAccessibleChat, })) vi.mock('@/lib/copilot/chat/messages-store', () => ({ @@ -49,7 +55,21 @@ describe('copilot chat stop route', () => { // Drain the once-queue (clearAllMocks/resetDbChainMock don't), then restore defaults. dbChainMockFns.limit.mockReset() resetDbChainMock() - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + authMockFns.mockGetSession.mockResolvedValue({ + user: { id: 'user-1' }, + session: { id: 'session-1' }, + }) + mockGetAccessibleChat.mockResolvedValue({ id: 'chat-1' }) + }) + + it('does not persist stopped content after organization access is removed', async () => { + mockGetAccessibleChat.mockResolvedValueOnce(null) + const response = await POST( + createRequest({ chatId: 'chat-1', streamId: 'stream-1', content: 'private' }) + ) + expect(response.status).toBe(200) + expect(dbChainMockFns.transaction).not.toHaveBeenCalled() + expect(mockAppendCopilotChatMessages).not.toHaveBeenCalled() }) it('returns 401 when unauthenticated', async () => { diff --git a/apps/sim/app/api/copilot/chat/stop/route.ts b/apps/sim/app/api/copilot/chat/stop/route.ts index 91f17dbcff3..ef02d470844 100644 --- a/apps/sim/app/api/copilot/chat/stop/route.ts +++ b/apps/sim/app/api/copilot/chat/stop/route.ts @@ -4,6 +4,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { copilotChatStopContract } from '@/lib/api/contracts/copilot' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' +import { getAccessibleCopilotChatAuth } from '@/lib/copilot/chat/lifecycle' import { normalizeMessage, type PersistedMessage, @@ -40,6 +41,10 @@ export const POST = withRouteHandler((req: NextRequest) => return parsed.response } const { chatId, streamId, content, contentBlocks, requestId } = parsed.data.body + const chat = await getAccessibleCopilotChatAuth(chatId, session.user.id, { + principal: { kind: 'session', userId: session.user.id, sessionId: session.session.id }, + }) + if (!chat) return NextResponse.json({ success: true }) span.setAttributes({ [TraceAttr.ChatId]: chatId, [TraceAttr.StreamId]: streamId, diff --git a/apps/sim/app/api/copilot/chat/stream/route.test.ts b/apps/sim/app/api/copilot/chat/stream/route.test.ts index aa7c85b250f..e93ca3121a1 100644 --- a/apps/sim/app/api/copilot/chat/stream/route.test.ts +++ b/apps/sim/app/api/copilot/chat/stream/route.test.ts @@ -10,13 +10,23 @@ import { MothershipStreamV1EventType, } from '@/lib/copilot/generated/mothership-stream-v1' -const { getLatestRunForStream, readEvents, readFilePreviewSessions, checkForReplayGap } = - vi.hoisted(() => ({ - getLatestRunForStream: vi.fn(), - readEvents: vi.fn(), - readFilePreviewSessions: vi.fn(), - checkForReplayGap: vi.fn(), - })) +const { + mockGetAccessibleChat, + getLatestRunForStream, + readEvents, + readFilePreviewSessions, + checkForReplayGap, +} = vi.hoisted(() => ({ + mockGetAccessibleChat: vi.fn(), + getLatestRunForStream: vi.fn(), + readEvents: vi.fn(), + readFilePreviewSessions: vi.fn(), + checkForReplayGap: vi.fn(), +})) + +vi.mock('@/lib/copilot/chat/lifecycle', () => ({ + getAccessibleCopilotChatAuth: mockGetAccessibleChat, +})) vi.mock('@/lib/copilot/async-runs/repository', () => ({ getLatestRunForStream, @@ -38,7 +48,6 @@ vi.mock('@/lib/copilot/request/session', () => ({ }), encodeSSEEnvelope: (event: Record) => new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`), - encodeSSEComment: (comment: string) => new TextEncoder().encode(`: ${comment}\n\n`), SSE_RESPONSE_HEADERS: { 'Content-Type': 'text/event-stream', }, @@ -66,6 +75,7 @@ async function readAllChunks(response: Response): Promise { describe('copilot chat stream replay route', () => { beforeEach(() => { vi.clearAllMocks() + mockGetAccessibleChat.mockResolvedValue({ id: 'chat-1' }) copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValue({ userId: 'user-1', isAuthenticated: true, @@ -75,6 +85,21 @@ describe('copilot chat stream replay route', () => { checkForReplayGap.mockResolvedValue(null) }) + it('refuses replay after organization membership is removed', async () => { + getLatestRunForStream.mockResolvedValueOnce({ + status: 'complete', + id: 'run-1', + chatId: 'chat-1', + }) + mockGetAccessibleChat.mockResolvedValueOnce(null) + const response = await GET( + new NextRequest('http://localhost:3000/api/copilot/chat/stream?streamId=stream-1&batch=true') + ) + expect(response.status).toBe(404) + expect(readEvents).not.toHaveBeenCalled() + expect(readFilePreviewSessions).not.toHaveBeenCalled() + }) + it('returns preview sessions in batch mode', async () => { getLatestRunForStream.mockResolvedValue({ status: 'active', diff --git a/apps/sim/app/api/copilot/chat/stream/route.ts b/apps/sim/app/api/copilot/chat/stream/route.ts index bd7f5465685..1a56c7f8435 100644 --- a/apps/sim/app/api/copilot/chat/stream/route.ts +++ b/apps/sim/app/api/copilot/chat/stream/route.ts @@ -1,4 +1,5 @@ import { type Context, context as otelContext, type Span, trace } from '@opentelemetry/api' +import type { Principal } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' @@ -6,6 +7,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { copilotChatStreamContract } from '@/lib/api/contracts/copilot' import { parseRequest } from '@/lib/api/server' import { getLatestRunForStream } from '@/lib/copilot/async-runs/repository' +import { getAccessibleCopilotChatAuth } from '@/lib/copilot/chat/lifecycle' import { MothershipStreamV1CompletionStatus, MothershipStreamV1EventType, @@ -22,13 +24,13 @@ import { getCopilotTracer, markSpanForError } from '@/lib/copilot/request/otel' import { checkForReplayGap, createEvent, - encodeSSEComment, encodeSSEEnvelope, readEvents, readFilePreviewSessions, SSE_RESPONSE_HEADERS, } from '@/lib/copilot/request/session' import { toStreamBatchEvent } from '@/lib/copilot/request/session/types' +import { encodeSSEComment } from '@/lib/core/utils/sse' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' export const maxDuration = 3600 @@ -115,8 +117,11 @@ function buildResumeTerminalEnvelopes(options: { } export const GET = withRouteHandler(async (request: NextRequest) => { - const { userId: authenticatedUserId, isAuthenticated } = - await authenticateCopilotRequestSessionOnly() + const { + userId: authenticatedUserId, + isAuthenticated, + principal, + } = await authenticateCopilotRequestSessionOnly() if (!isAuthenticated || !authenticatedUserId) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) @@ -169,6 +174,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { afterCursor, batchMode, authenticatedUserId, + principal, rootSpan, rootContext, }) @@ -186,6 +192,7 @@ async function handleResumeRequestBody({ afterCursor, batchMode, authenticatedUserId, + principal, rootSpan, rootContext, }: { @@ -194,6 +201,7 @@ async function handleResumeRequestBody({ afterCursor: string batchMode: boolean authenticatedUserId: string + principal?: Principal rootSpan: Span rootContext: Context }) { @@ -211,7 +219,11 @@ async function handleResumeRequestBody({ hasRun: !!run, runStatus: run?.status, }) - if (!run) { + if ( + !run || + (run.chatId && + !(await getAccessibleCopilotChatAuth(run.chatId, authenticatedUserId, { principal }))) + ) { rootSpan.setAttribute(TraceAttr.CopilotResumeOutcome, CopilotResumeOutcome.StreamNotFound) rootSpan.end() return NextResponse.json({ error: 'Stream not found' }, { status: 404 }) @@ -323,6 +335,13 @@ async function handleResumeRequestBody({ request.signal.addEventListener('abort', abortListener, { once: true }) const flushEvents = async () => { + if ( + run?.chatId && + !(await getAccessibleCopilotChatAuth(run.chatId, authenticatedUserId, { principal })) + ) { + closeController() + return + } const events = await readEvents(streamId, cursor) if (events.length > 0) { logger.debug('[Resume] Flushing events', { diff --git a/apps/sim/app/api/copilot/tools/execute/route.ts b/apps/sim/app/api/copilot/tools/execute/route.ts index d247d93ee99..b6a0c57c2ec 100644 --- a/apps/sim/app/api/copilot/tools/execute/route.ts +++ b/apps/sim/app/api/copilot/tools/execute/route.ts @@ -41,16 +41,20 @@ const turnRegistryCache = new Map< async function getTurnEgressRegistry( userId: string, workspaceId: string | undefined, - messageId: string | undefined + messageId: string | undefined, + requestMode?: string, + organizationId?: string ): Promise { - const key = `${userId}\u0000${workspaceId ?? ''}\u0000${messageId ?? ''}` + const key = `${userId}\u0000${workspaceId ?? ''}\u0000${organizationId ?? ''}\u0000${messageId ?? ''}\u0000${requestMode ?? ''}` const now = Date.now() const hit = turnRegistryCache.get(key) if (hit && hit.expiresAt > now) { hit.expiresAt = now + TURN_REGISTRY_TTL_MS return hit.registry } - const environmentContext = await prepareCopilotEnvironmentContext(userId, workspaceId) + const environmentContext = await prepareCopilotEnvironmentContext(userId, workspaceId, { + includeSecrets: requestMode !== 'assistant', + }) for (const [cachedKey, cached] of turnRegistryCache) { if (cached.expiresAt <= now) turnRegistryCache.delete(cachedKey) } @@ -107,10 +111,13 @@ export const POST = withRouteHandler((request: NextRequest) => userId, workflowId, workspaceId, + organizationId, chatId, messageId, parentToolCallId, userPermission, + requestMode, + assistantSearch, } = validation.data rootSpan.setAttributes({ [TraceAttr.ToolName]: toolName, @@ -121,7 +128,13 @@ export const POST = withRouteHandler((request: NextRequest) => let toolRegistry: ResolvedSecretTraceRegistry let turnRegistry: ResolvedSecretTraceRegistry try { - turnRegistry = await getTurnEgressRegistry(userId, workspaceId, messageId) + turnRegistry = await getTurnEgressRegistry( + userId, + workspaceId, + messageId, + requestMode, + organizationId + ) toolRegistry = turnRegistry.forkForInputPaths([]) } catch (err) { /** @@ -167,12 +180,16 @@ export const POST = withRouteHandler((request: NextRequest) => userId, workflowId: workflowId ?? '', workspaceId, + organizationId, chatId, messageId, toolCallId, parentToolCallId, userPermission, copilotToolExecution: true, + copilotInteractionMode: 'interactive', + requestMode, + assistantSearch, resolvedSecretTraceRegistry: toolRegistry, }) const projection = inspectToolResultForCopilot(result, toolRegistry, toolName) diff --git a/apps/sim/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route.test.ts b/apps/sim/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route.test.ts index ff16fe62b7c..621463df095 100644 --- a/apps/sim/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route.test.ts +++ b/apps/sim/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route.test.ts @@ -38,9 +38,9 @@ const context = { params: Promise.resolve({ token: 'invitation-token', optionId: 'option-1' }), } -function request() { +function request(query = '') { return new NextRequest( - 'http://localhost:3000/api/credential-groups/enroll/invitation-token/oauth/option-1' + `http://localhost:3000/api/credential-groups/enroll/invitation-token/oauth/option-1${query}` ) } @@ -69,6 +69,41 @@ describe('credential group OAuth start route', () => { }) }) + it('forwards only the closed Search return context to the authorized operation', async () => { + await GET(request('?returnTo=search'), context) + expect(mocks.startOAuth).toHaveBeenCalledWith( + expect.objectContaining({ + principal, + input: { invitationToken: 'invitation-token', optionId: 'option-1', returnTo: 'search' }, + }) + ) + mocks.startOAuth.mockClear() + const response = await GET(request('?returnTo=https://external.test'), context) + expect(response.status).toBe(400) + expect(mocks.startOAuth).not.toHaveBeenCalled() + }) + + it.each(['ip', 'enrollment', 'unavailable', 'configuration'])( + 'preserves exact Search focus after %s failure', + async (failure) => { + if (failure === 'ip') + mocks.ipRateLimit.mockResolvedValue(NextResponse.json({}, { status: 429 })) + if (failure === 'enrollment') + mocks.enrollmentRateLimit.mockResolvedValue(NextResponse.json({}, { status: 429 })) + if (failure === 'unavailable') mocks.authenticate.mockResolvedValue(null) + if (failure === 'configuration') + mocks.startOAuth.mockRejectedValue(new Error('Unavailable configuration')) + const response = await GET(request('?returnTo=search'), context) + const location = new URL(response.headers.get('location')!, 'http://localhost') + expect(location.pathname).toBe('/credential-groups/enroll/invitation-token') + expect(location.searchParams.get('optionId')).toBe('option-1') + expect(location.searchParams.get('returnTo')).toBe('search') + expect(location.searchParams.get('oauth')).toBe( + failure === 'ip' || failure === 'enrollment' ? 'rate_limited' : 'unavailable' + ) + } + ) + it('returns an unavailable enrollment to its public page', async () => { mocks.authenticate.mockResolvedValue(null) diff --git a/apps/sim/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route.ts b/apps/sim/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route.ts index bc66315f68e..aa5582d7263 100644 --- a/apps/sim/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route.ts +++ b/apps/sim/app/api/credential-groups/enroll/[token]/oauth/[optionId]/route.ts @@ -29,25 +29,27 @@ export const GET = withRouteHandler( const parsed = await parseRequest(startCredentialGroupOAuthContract, request, context) if (!parsed.success) return limited ?? parsed.response const { token, optionId } = parsed.data.params + const { returnTo } = parsed.data.query + const focus: Record = returnTo ? { optionId, returnTo } : {} if (limited) { - return createCredentialGroupEnrollmentRedirect(token, { oauth: 'rate_limited' }) + return createCredentialGroupEnrollmentRedirect(token, { ...focus, oauth: 'rate_limited' }) } const principal = await authenticateCredentialGroupEnrollment(token) if (!principal) { - return createCredentialGroupEnrollmentRedirect(token, { oauth: 'unavailable' }) + return createCredentialGroupEnrollmentRedirect(token, { ...focus, oauth: 'unavailable' }) } const enrollmentLimited = await enforceCredentialGroupEnrollmentOAuthRateLimit( principal.enrollmentId ) if (enrollmentLimited) { - return createCredentialGroupEnrollmentRedirect(token, { oauth: 'rate_limited' }) + return createCredentialGroupEnrollmentRedirect(token, { ...focus, oauth: 'rate_limited' }) } try { const { authorizationUrl } = await startPublicCredentialGroupOAuth.execute({ principal, - input: { invitationToken: token, optionId }, + input: { invitationToken: token, optionId, ...(returnTo ? { returnTo } : {}) }, request, }) const response = NextResponse.redirect(authorizationUrl) @@ -59,6 +61,7 @@ export const GET = withRouteHandler( error: getErrorMessage(error), }) return createCredentialGroupEnrollmentRedirect(token, { + ...focus, oauth: error instanceof CredentialGroupOAuthError && error.statusCode === 409 ? 'configuration_changed' diff --git a/apps/sim/app/api/credential-groups/enrollment-redirect.ts b/apps/sim/app/api/credential-groups/enrollment-redirect.ts index b2a34e897bf..3ac896cdf46 100644 --- a/apps/sim/app/api/credential-groups/enrollment-redirect.ts +++ b/apps/sim/app/api/credential-groups/enrollment-redirect.ts @@ -20,11 +20,22 @@ export function createCredentialGroupEnrollmentRedirect( }) } -export function createCredentialGroupCompletionRedirect(): NextResponse { +export type CredentialGroupOAuthFailure = + | 'denied' + | 'account_mismatch' + | 'permissions_required' + | 'configuration_changed' + | 'rate_limited' + | 'unavailable' + | 'failed' + +export function createCredentialGroupCompletionRedirect( + oauth?: CredentialGroupOAuthFailure +): NextResponse { return new NextResponse(null, { status: 303, headers: { - Location: '/credential-groups/complete', + Location: `/credential-groups/complete${oauth ? `?oauth=${oauth}` : ''}`, ...NO_STORE_REDIRECT_HEADERS, }, }) diff --git a/apps/sim/app/api/credential-groups/oauth-callback.ts b/apps/sim/app/api/credential-groups/oauth-callback.ts index 217ff6b8aa0..67d998aa631 100644 --- a/apps/sim/app/api/credential-groups/oauth-callback.ts +++ b/apps/sim/app/api/credential-groups/oauth-callback.ts @@ -3,15 +3,20 @@ import { getErrorMessage } from '@sim/utils/errors' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import type { CredentialGroupOAuthCallbackQuery } from '@/lib/api/contracts/credential-groups' -import { authenticateCredentialGroupEnrollment } from '@/lib/credential-groups/application/enrollment-auth' +import { credentialGroupOAuthAttemptPrincipal } from '@/lib/credential-groups/application/enrollment-auth' import { completePublicCredentialGroupOAuth } from '@/lib/credential-groups/application/public-enrollment' +import { CredentialGroupOAuthStateVersionError } from '@/lib/credential-groups/oauth-attempt-version' import { consumeCredentialGroupOAuthAttempt } from '@/lib/credential-groups/oauth-state' import { CredentialGroupInvitationUnavailableError, CredentialGroupOAuthError, } from '@/lib/credential-groups/provider-adapter' import type { CredentialGroupProvider } from '@/lib/credential-groups/providers' -import { createCredentialGroupEnrollmentRedirect } from '@/app/api/credential-groups/enrollment-redirect' +import { + type CredentialGroupOAuthFailure, + createCredentialGroupCompletionRedirect, + createCredentialGroupEnrollmentRedirect, +} from '@/app/api/credential-groups/enrollment-redirect' const logger = createLogger('CredentialGroupOAuthCallbackAPI') @@ -34,6 +39,12 @@ export async function handleCredentialGroupOAuthCallback({ try { attempt = await consumeCredentialGroupOAuthAttempt(state) } catch (error) { + if (error instanceof CredentialGroupOAuthStateVersionError) { + return NextResponse.json( + { error: error.message }, + { status: 400, headers: { 'Cache-Control': 'no-store' } } + ) + } logger.error('Failed to consume credential group OAuth state', { error: getErrorMessage(error), }) @@ -49,34 +60,36 @@ export async function handleCredentialGroupOAuthCallback({ { status: 400, headers: { 'Cache-Control': 'no-store' } } ) } + const focus: Record = attempt.returnTo + ? { optionId: attempt.optionId, returnTo: attempt.returnTo } + : {} + const failureRedirect = (oauth: CredentialGroupOAuthFailure) => + attempt.completionRedirect + ? createCredentialGroupCompletionRedirect(oauth) + : createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { ...focus, oauth }) if (limited) { - return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { - oauth: 'rate_limited', - }) + return failureRedirect('rate_limited') } if (providerError) { - return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { oauth: 'denied' }) + return failureRedirect('denied') } if (!code) { - return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { oauth: 'failed' }) - } - - const principal = await authenticateCredentialGroupEnrollment(attempt.invitationToken) - if (!principal) { - return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { - oauth: 'unavailable', - }) + return failureRedirect('failed') } try { + const principal = await credentialGroupOAuthAttemptPrincipal(attempt) await completePublicCredentialGroupOAuth.execute({ principal, input: { attempt, code }, request, }) - return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { - connected: attempt.optionId, - }) + return attempt.completionRedirect + ? createCredentialGroupCompletionRedirect() + : createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { + ...focus, + connected: attempt.optionId, + }) } catch (error) { logger.error('Managed OAuth authorization failed', { provider, @@ -92,6 +105,6 @@ export async function handleCredentialGroupOAuthCallback({ : error instanceof CredentialGroupOAuthError && error.statusCode === 409 ? 'configuration_changed' : 'failed' - return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { oauth: status }) + return failureRedirect(status) } } diff --git a/apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.test.ts b/apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.test.ts index 0a402c66585..7056e109ba9 100644 --- a/apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.test.ts +++ b/apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.test.ts @@ -3,6 +3,7 @@ */ import { NextRequest, NextResponse } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { CredentialGroupOAuthStateVersionError } from '@/lib/credential-groups/oauth-attempt-version' const mocks = vi.hoisted(() => ({ authenticate: vi.fn(), @@ -12,7 +13,7 @@ const mocks = vi.hoisted(() => ({ })) vi.mock('@/lib/credential-groups/application/enrollment-auth', () => ({ - authenticateCredentialGroupEnrollment: mocks.authenticate, + credentialGroupOAuthAttemptPrincipal: mocks.authenticate, })) vi.mock('@/lib/credential-groups/application/public-enrollment', () => ({ @@ -27,7 +28,10 @@ vi.mock('@/lib/credential-groups/rate-limit', () => ({ enforcePublicCredentialGroupIpRateLimit: mocks.rateLimit, })) -import { CredentialGroupInvitationUnavailableError } from '@/lib/credential-groups/provider-adapter' +import { + CredentialGroupInvitationUnavailableError, + CredentialGroupOAuthError, +} from '@/lib/credential-groups/provider-adapter' import { GET } from '@/app/api/credential-groups/oauth/[provider]/callback/route' const principal = { @@ -56,7 +60,7 @@ describe('credential group OAuth callback', () => { vi.clearAllMocks() mocks.rateLimit.mockResolvedValue(null) mocks.consumeAttempt.mockResolvedValue(attempt) - mocks.authenticate.mockResolvedValue(principal) + mocks.authenticate.mockReturnValue(principal) mocks.completeOAuth.mockResolvedValue({ connectedOptionId: 'option-1' }) }) @@ -65,6 +69,7 @@ describe('credential group OAuth callback', () => { const response = await GET(callbackRequest, context) expect(mocks.consumeAttempt).toHaveBeenCalledWith('state-1') + expect(mocks.authenticate).toHaveBeenCalledWith(attempt) expect(mocks.completeOAuth).toHaveBeenCalledWith({ principal, input: { attempt, code: 'code-1' }, @@ -76,6 +81,56 @@ describe('credential group OAuth callback', () => { ) }) + it('restores the exact focused option after a successful Search connection', async () => { + mocks.consumeAttempt.mockResolvedValue({ ...attempt, optionId: 'site-two', returnTo: 'search' }) + const response = await GET(request('state=state-1&code=code-1'), context) + expect(response.headers.get('location')).toBe( + '/credential-groups/enroll/invitation-token?optionId=site-two&returnTo=search&connected=site-two' + ) + expect(mocks.completeOAuth).toHaveBeenCalledWith( + expect.objectContaining({ + principal, + input: expect.objectContaining({ + attempt: expect.objectContaining({ optionId: 'site-two' }), + }), + }) + ) + }) + + it.each([ + [new CredentialGroupInvitationUnavailableError(), 'unavailable'], + [new CredentialGroupOAuthError('Sign in with your own account', 403), 'account_mismatch'], + [new CredentialGroupOAuthError('Missing scopes', 403), 'permissions_required'], + [new CredentialGroupOAuthError('Changed settings', 409), 'configuration_changed'], + [new Error('Provider failed'), 'failed'], + ])('retains Search focus after a rejected provider exchange: %s', async (error, status) => { + mocks.consumeAttempt.mockResolvedValue({ ...attempt, returnTo: 'search' }) + mocks.completeOAuth.mockRejectedValueOnce(error) + const response = await GET(request('state=state-1&code=code-1'), context) + expect(response.headers.get('location')).toBe( + `/credential-groups/enroll/invitation-token?optionId=option-1&returnTo=search&oauth=${status}` + ) + }) + + it.each(['denied', 'rate_limited'])( + 'retains Search focus without exchanging after %s', + async (status) => { + mocks.consumeAttempt.mockResolvedValue({ ...attempt, returnTo: 'search' }) + if (status === 'rate_limited') + mocks.rateLimit.mockResolvedValue(NextResponse.json({}, { status: 429 })) + const response = await GET( + request( + status === 'denied' ? 'state=state-1&error=access_denied' : 'state=state-1&code=code-1' + ), + context + ) + expect(response.headers.get('location')).toBe( + `/credential-groups/enroll/invitation-token?optionId=option-1&returnTo=search&oauth=${status}` + ) + expect(mocks.completeOAuth).not.toHaveBeenCalled() + } + ) + it('rejects standard providers on the custom callback route', async () => { const response = await GET( new NextRequest( @@ -113,7 +168,7 @@ describe('credential group OAuth callback', () => { }) it('returns an unavailable enrollment redirect when the invitation was revoked in flight', async () => { - mocks.authenticate.mockResolvedValue(null) + mocks.completeOAuth.mockRejectedValueOnce(new CredentialGroupInvitationUnavailableError()) const response = await GET(request('state=state-1&code=code-1'), context) @@ -121,7 +176,7 @@ describe('credential group OAuth callback', () => { expect(response.headers.get('location')).toBe( '/credential-groups/enroll/invitation-token?oauth=unavailable' ) - expect(mocks.completeOAuth).not.toHaveBeenCalled() + expect(mocks.completeOAuth).toHaveBeenCalledOnce() }) it('returns an unavailable enrollment redirect when the invitation is revoked during exchange', async () => { @@ -150,4 +205,59 @@ describe('credential group OAuth callback', () => { expect(mocks.authenticate).not.toHaveBeenCalled() expect(mocks.completeOAuth).not.toHaveBeenCalled() }) + + it('returns personal connections to the fixed completion page after invitation rotation', async () => { + mocks.consumeAttempt.mockResolvedValue({ ...attempt, completionRedirect: true }) + const response = await GET(request('state=state-1&code=code-1'), context) + expect(response.status).toBe(303) + expect(response.headers.get('location')).toBe('/credential-groups/complete') + expect(response.headers.get('cache-control')).toBe('no-store') + expect(response.headers.get('referrer-policy')).toBe('no-referrer') + }) + + it.each([['error=access_denied', 'denied']])( + 'shows personal callback failure without reopening a stale invitation: %s', + async (query, status) => { + mocks.consumeAttempt.mockResolvedValue({ ...attempt, completionRedirect: true }) + const response = await GET(request(`state=state-1&${query}`), context) + expect(response.status).toBe(303) + expect(response.headers.get('location')).toBe(`/credential-groups/complete?oauth=${status}`) + expect(mocks.completeOAuth).not.toHaveBeenCalled() + } + ) + + it.each([ + [new CredentialGroupInvitationUnavailableError(), 'unavailable'], + [new CredentialGroupOAuthError('Sign in with your own account', 403), 'account_mismatch'], + [new CredentialGroupOAuthError('Missing scopes', 403), 'permissions_required'], + [new CredentialGroupOAuthError('Changed settings', 409), 'configuration_changed'], + [new Error('Provider failed'), 'failed'], + ])('shows failed personal authorization on the completion page', async (error, status) => { + mocks.consumeAttempt.mockResolvedValue({ ...attempt, completionRedirect: true }) + mocks.completeOAuth.mockRejectedValueOnce(error) + const response = await GET(request('state=state-1&code=code-1'), context) + expect(response.status).toBe(303) + expect(response.headers.get('location')).toBe(`/credential-groups/complete?oauth=${status}`) + }) + + it('shows rate limits on the personal completion page without exchanging', async () => { + mocks.consumeAttempt.mockResolvedValue({ ...attempt, completionRedirect: true }) + mocks.rateLimit.mockResolvedValue( + NextResponse.json({ error: 'Too many requests' }, { status: 429 }) + ) + const response = await GET(request('state=state-1&code=code-1'), context) + expect(response.status).toBe(303) + expect(response.headers.get('location')).toBe('/credential-groups/complete?oauth=rate_limited') + expect(mocks.completeOAuth).not.toHaveBeenCalled() + }) + it('reports a state protocol change as an explicit restart without exchanging a code', async () => { + mocks.consumeAttempt.mockRejectedValue(new CredentialGroupOAuthStateVersionError()) + const response = await GET(request('state=state-1&code=code-1'), context) + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ + error: expect.stringContaining('Reopen your invitation and connect again'), + }) + expect(mocks.authenticate).not.toHaveBeenCalled() + expect(mocks.completeOAuth).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/app/api/credentials/personal/connect/route.ts b/apps/sim/app/api/credentials/personal/connect/route.ts new file mode 100644 index 00000000000..d7ea060700b --- /dev/null +++ b/apps/sim/app/api/credentials/personal/connect/route.ts @@ -0,0 +1,20 @@ +import { startPersonalCredentialConnectionContract } from '@/lib/api/contracts/credentials' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalPersonalCredentialConnectionErrorPolicy } from '@/lib/credentials/api/route-policies' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { startPersonalCredentialConnection } from '@/lib/credentials/application/personal-connection' + +export const POST = defineInternalJsonRoute({ + contract: startPersonalCredentialConnectionContract, + auth: internalSessionAuth, + operation: credentialOperations.startPersonalConnection, + rateLimit: internalRateLimits.user({ bucketName: 'credentials.personal.connect' }), + errorPolicy: internalPersonalCredentialConnectionErrorPolicy, + mapInput: ({ body }) => body, + useCase: startPersonalCredentialConnection, + present: (result) => result, +}) diff --git a/apps/sim/app/api/credentials/personal/route.ts b/apps/sim/app/api/credentials/personal/route.ts new file mode 100644 index 00000000000..61eb9d0f1cb --- /dev/null +++ b/apps/sim/app/api/credentials/personal/route.ts @@ -0,0 +1,26 @@ +import { listPersonalCredentialsContract } from '@/lib/api/contracts/credentials' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalCredentialErrorPolicy } from '@/lib/credentials/api/route-policies' +import { credentialOperations } from '@/lib/credentials/application/operations' +import { listPersonalCredentials } from '@/lib/credentials/application/personal-credentials' + +export const GET = defineInternalJsonRoute({ + contract: listPersonalCredentialsContract, + auth: internalSessionAuth, + operation: credentialOperations.listPersonal, + rateLimit: internalRateLimits.user({ bucketName: 'credentials.personal.list' }), + errorPolicy: internalCredentialErrorPolicy, + mapInput: ({ query }) => query, + useCase: listPersonalCredentials, + present: ({ credentials }) => ({ + credentials: credentials.map((entry) => ({ + ...entry, + updatedAt: entry.updatedAt.toISOString(), + connectedAt: entry.connectedAt.toISOString(), + })), + }), +}) diff --git a/apps/sim/app/api/cron/cleanup-oauth-tokens/route.ts b/apps/sim/app/api/cron/cleanup-oauth-tokens/route.ts new file mode 100644 index 00000000000..bfa925e77a8 --- /dev/null +++ b/apps/sim/app/api/cron/cleanup-oauth-tokens/route.ts @@ -0,0 +1,32 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { verifyCronAuth } from '@/lib/auth/internal' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { runCleanupOAuthTokens } from '@/background/cleanup-oauth-tokens' + +export const dynamic = 'force-dynamic' +export const maxDuration = 60 + +const logger = createLogger('CleanupOAuthTokensAPI') + +/** + * Retention sweep for lapsed OAuth token rows. Issuance state does not gate + * retention: a deployment that disables the provider must still drain rows + * created while it was enabled. + */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const authError = verifyCronAuth(request, 'OAuth token cleanup') + if (authError) return authError + + try { + const result = await runCleanupOAuthTokens() + return NextResponse.json({ success: true, ...result }) + } catch (error) { + logger.error('Failed to sweep expired OAuth tokens', { error }) + return NextResponse.json( + { error: getErrorMessage(error, 'Failed to sweep expired OAuth tokens') }, + { status: 500 } + ) + } +}) diff --git a/apps/sim/app/api/cron/scim-reconcile/route.ts b/apps/sim/app/api/cron/scim-reconcile/route.ts new file mode 100644 index 00000000000..c429bdddb96 --- /dev/null +++ b/apps/sim/app/api/cron/scim-reconcile/route.ts @@ -0,0 +1,31 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { type NextRequest, NextResponse } from 'next/server' +import { verifyCronAuth } from '@/lib/auth/internal' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { isScimDeploymentEnabled } from '@/ee/scim/lib/entitlement' +import { runScimReconcileSweep } from '@/ee/scim/lib/reconcile/job' + +const logger = createLogger('CronScimReconcile') + +/** + * Sweeps directory connections for drift between what their group mappings say + * a member should have and what SCIM actually granted them. + */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const authError = verifyCronAuth(request, 'SCIM reconciliation') + if (authError) return authError + + if (!isScimDeploymentEnabled()) { + return NextResponse.json({ success: true, connections: 0, skipped: 'disabled' }) + } + + try { + const sweep = await runScimReconcileSweep() + logger.info('SCIM reconciliation sweep complete', sweep) + return NextResponse.json({ success: true, ...sweep }) + } catch (error) { + logger.error('SCIM reconciliation sweep failed', { error: getErrorMessage(error) }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/enterprise-owner-claims/[id]/route.test.ts b/apps/sim/app/api/enterprise-owner-claims/[id]/route.test.ts index 65e2fe04550..cfc34b07b96 100644 --- a/apps/sim/app/api/enterprise-owner-claims/[id]/route.test.ts +++ b/apps/sim/app/api/enterprise-owner-claims/[id]/route.test.ts @@ -84,7 +84,7 @@ describe('Enterprise owner claim routes', () => { mocks.acceptClaim.mockResolvedValue({ success: true, claim, - redirectPath: '/workspace', + redirectPath: '/home', }) const response = await POST( diff --git a/apps/sim/app/api/files/authorization.test.ts b/apps/sim/app/api/files/authorization.test.ts index f0ac6468536..13aaf73db66 100644 --- a/apps/sim/app/api/files/authorization.test.ts +++ b/apps/sim/app/api/files/authorization.test.ts @@ -33,7 +33,9 @@ vi.mock('@/lib/uploads/server/metadata', () => ({ })) vi.mock('@/lib/uploads/utils/file-utils', () => ({ - inferContextFromKey: vi.fn(() => 'knowledge-base'), + inferContextFromKey: vi.fn((key: string) => + key.startsWith('kb/') ? 'knowledge-base' : key.split('/')[0] + ), })) vi.mock('@/lib/workspaces/permissions/utils', () => ({ @@ -44,6 +46,7 @@ vi.mock('@/executor/constants', () => ({ isUuid: vi.fn(() => false), })) +import { SYSTEM_ACCESS_SCOPE } from '@/lib/knowledge/access/types' import { verifyFileAccess, verifyKBFileWriteAccess } from '@/app/api/files/authorization' const CLOUD_KEY = 'kb/1780162789495-secret.txt' @@ -322,3 +325,70 @@ describe('workspace-scoped access (workspace files and mothership attachments)', expect(mockGetUserEntityPermissions).not.toHaveBeenCalled() }) }) + +describe('organization connector cache access', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetFileMetadataByKey.mockResolvedValue({ + workspaceId: null, + organizationId: 'org-1', + userId: USER_ID, + deletedAt: null, + }) + mockGetUserEntityPermissions.mockResolvedValue('admin') + mockGetFileMetadata.mockResolvedValue({ userId: USER_ID }) + dbChainMockFns.limit.mockResolvedValue([{ id: 'doc-1' }]) + }) + + it.each(['general', 'profile-pictures', 'knowledge-base'] as const)( + 'denies the uploader a raw download even with a forged %s context', + async (context) => { + await expect( + verifyFileAccess(CLOUD_KEY, USER_ID, undefined, context, false, { knowledgeAccess: 'user' }) + ).resolves.toBe(false) + expect(mockGetFileMetadata).not.toHaveBeenCalled() + expect(mockGetUserEntityPermissions).not.toHaveBeenCalled() + } + ) + + it('allows the internal processor to read a live bound connector cache', async () => { + await expect( + verifyFileAccess(CLOUD_KEY, USER_ID, undefined, 'knowledge-base', false, { + knowledgeAccess: SYSTEM_ACCESS_SCOPE, + }) + ).resolves.toBe(true) + expect(mockGetUserEntityPermissions).not.toHaveBeenCalled() + }) + + it('denies system reads after the cache loses its active document reference', async () => { + dbChainMockFns.limit.mockResolvedValue([]) + await expect( + verifyFileAccess(CLOUD_KEY, USER_ID, undefined, 'knowledge-base', false, { + knowledgeAccess: SYSTEM_ACCESS_SCOPE, + }) + ).resolves.toBe(false) + }) + + it('denies a binding claiming both organization and workspace ownership', async () => { + mockGetFileMetadataByKey.mockResolvedValue({ + organizationId: 'org-1', + workspaceId: 'ws-1', + deletedAt: null, + }) + await expect( + verifyFileAccess(CLOUD_KEY, USER_ID, undefined, 'knowledge-base', false, { + knowledgeAccess: SYSTEM_ACCESS_SCOPE, + }) + ).resolves.toBe(false) + await expect(verifyKBFileWriteAccess(CLOUD_KEY, USER_ID)).resolves.toBe(false) + }) + + it('does not let a raw download endpoint delete organization caches', async () => { + await expect( + verifyFileAccess(CLOUD_KEY, USER_ID, undefined, 'general', false, { + requireWrite: true, + knowledgeAccess: SYSTEM_ACCESS_SCOPE, + }) + ).resolves.toBe(false) + }) +}) diff --git a/apps/sim/app/api/files/authorization.ts b/apps/sim/app/api/files/authorization.ts index dbac5ed031e..a847a5748ac 100644 --- a/apps/sim/app/api/files/authorization.ts +++ b/apps/sim/app/api/files/authorization.ts @@ -2,8 +2,10 @@ import { db } from '@sim/db' import { document, knowledgeBase, workspaceFile } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { permissionSatisfies } from '@sim/platform-authz/workspace' -import { and, eq, isNull } from 'drizzle-orm' +import { and, eq, isNotNull, isNull, or } from 'drizzle-orm' import { NextResponse } from 'next/server' +import type { ResourceScope } from '@/lib/core/resource-scope' +import { resourceScopeCondition } from '@/lib/core/resource-scope.server' import { knowledgeAccessCondition } from '@/lib/knowledge/access/predicate' import { resolveUserKnowledgeAccessScope, @@ -151,6 +153,12 @@ export async function verifyFileAccess( ): Promise { const requireWrite = options?.requireWrite ?? false try { + const keyContext = inferContextFromKey(cloudKey) + if (keyContext === 'knowledge-base') { + return requireWrite + ? verifyKBFileWriteAccess(cloudKey, userId) + : verifyKBFileAccess(cloudKey, userId, customConfig, options?.knowledgeAccess) + } if (context === 'general') { return await verifyRegularFileAccess(cloudKey, userId, customConfig, isLocal, requireWrite) } @@ -188,7 +196,9 @@ export async function verifyFileAccess( // 4. KB files: kb/filename if (inferredContext === 'knowledge-base') { - return await verifyKBFileAccess(cloudKey, userId, customConfig, options?.knowledgeAccess) + return requireWrite + ? verifyKBFileWriteAccess(cloudKey, userId) + : verifyKBFileAccess(cloudKey, userId, customConfig, options?.knowledgeAccess) } // 5. Chat files: chat/filename @@ -310,7 +320,7 @@ async function verifyPublicAssetWriteAccess( try { if (context === 'workspace-logos') { const binding = await getFileMetadataByKey(cloudKey, 'workspace-logos') - if (!binding?.workspaceId) { + if (!binding?.workspaceId || binding.organizationId || binding.deletedAt) { logger.warn('workspace-logos delete denied: no ownership binding', { userId, cloudKey }) return false } @@ -496,7 +506,7 @@ type ResolvedKnowledgeFileAccess = KnowledgeAccessScope | SystemAccessScope async function hasActiveKbDocumentForKey( cloudKey: string, - workspaceId: string, + scope: ResourceScope, access: ResolvedKnowledgeFileAccess ): Promise { const rows = await db @@ -505,12 +515,15 @@ async function hasActiveKbDocumentForKey( .innerJoin(knowledgeBase, eq(document.knowledgeBaseId, knowledgeBase.id)) .where( and( - eq(knowledgeBase.workspaceId, workspaceId), + resourceScopeCondition(knowledgeBase, scope), eq(document.storageKey, cloudKey), eq(document.userExcluded, false), isNull(document.archivedAt), isNull(document.deletedAt), isNull(knowledgeBase.deletedAt), + access.kind === 'system' + ? undefined + : or(isNull(document.connectorId), isNotNull(document.contentHash)), knowledgeAccessCondition(access) ) ) @@ -572,6 +585,19 @@ async function verifyKBFileAccess( logger.warn('KB file access denied for deleted file binding', { userId, cloudKey }) return false } + if (binding.organizationId) { + if ( + binding.workspaceId || + typeof knowledgeAccess !== 'object' || + knowledgeAccess.kind !== 'system' + ) + return false + return hasActiveKbDocumentForKey( + cloudKey, + { kind: 'organization', organizationId: binding.organizationId }, + knowledgeAccess + ) + } if (!binding.workspaceId) { logger.warn('KB file binding missing workspace owner', { userId, cloudKey }) return false @@ -588,7 +614,13 @@ async function verifyKBFileAccess( } const access = await resolveKnowledgeFileAccess(knowledgeAccess, userId, binding.workspaceId) - if (!(await hasActiveKbDocumentForKey(cloudKey, binding.workspaceId, access))) { + if ( + !(await hasActiveKbDocumentForKey( + cloudKey, + { kind: 'workspace', workspaceId: binding.workspaceId }, + access + )) + ) { logger.warn('KB file access denied: no readable document references the file', { userId, cloudKey, @@ -619,7 +651,7 @@ async function verifyKBFileAccess( export async function verifyKBFileWriteAccess(cloudKey: string, userId: string): Promise { try { const binding = await getFileMetadataByKey(cloudKey, 'knowledge-base') - if (!binding?.workspaceId) { + if (!binding?.workspaceId || binding.organizationId || binding.deletedAt) { logger.warn('KB file delete denied: no ownership binding', { userId, cloudKey }) return false } diff --git a/apps/sim/app/api/files/export/[id]/route.test.ts b/apps/sim/app/api/files/export/[id]/route.test.ts index d28279f83f0..d8c812c5215 100644 --- a/apps/sim/app/api/files/export/[id]/route.test.ts +++ b/apps/sim/app/api/files/export/[id]/route.test.ts @@ -1,10 +1,13 @@ /** * @vitest-environment node */ + +import { recordAudit } from '@sim/audit' import { createMockRequest } from '@sim/testing' import JSZip from 'jszip' import { beforeEach, describe, expect, it, vi } from 'vitest' import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' +import { getServeStoragePrefix } from '@/lib/uploads/config' const { mockCheckAuth, @@ -92,6 +95,62 @@ beforeEach(() => { }) describe('markdown export bundling', () => { + it('rejects an unauthenticated request before reading file metadata', async () => { + mockCheckAuth.mockResolvedValue({ success: false }) + const response = await GET(request(), context) + + expect(response.status).toBe(401) + expect(await response.json()).toEqual({ error: 'Unauthorized' }) + expect(mockGetFileMetadataById).not.toHaveBeenCalled() + expect(recordAudit).not.toHaveBeenCalled() + }) + + it('preserves legacy missing-file and access-denied responses', async () => { + mockGetFileMetadataById.mockResolvedValueOnce(null) + const missing = await GET(request(), context) + expect(missing.status).toBe(404) + expect(await missing.json()).toEqual({ error: 'Not found' }) + + mockVerifyFileAccess.mockResolvedValue(false) + const forbidden = await GET(request(), context) + expect(forbidden.status).toBe(403) + expect(await forbidden.json()).toEqual({ error: 'Forbidden' }) + expect(mockDownloadFile).not.toHaveBeenCalled() + expect(recordAudit).not.toHaveBeenCalled() + }) + + it('keeps non-Markdown downloads as authorized serve redirects', async () => { + mockGetFileMetadataById.mockResolvedValue({ + ...DOC_RECORD, + originalName: 'report.pdf', + contentType: 'application/pdf', + context: 'chat', + }) + const response = await GET(request(), context) + + expect(response.status).toBe(302) + expect(response.headers.get('location')).toContain( + `/api/files/serve/${getServeStoragePrefix()}/${encodeURIComponent(DOC_RECORD.key)}` + ) + expect(mockDownloadFile).not.toHaveBeenCalled() + expect(recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ format: 'file', assetCount: 0 }), + }) + ) + }) + + it('preserves exact plain Markdown bytes and download headers', async () => { + const content = '\uFEFF---\r\ntitle: "Résumé"\r\n---\r\n\r\n# 你好 😀\r\n' + mockDownloadFile.mockResolvedValue(Buffer.from(content)) + const response = await GET(request(), context) + + expect(Buffer.from(await response.arrayBuffer())).toEqual(Buffer.from(content)) + expect(response.headers.get('content-type')).toBe('text/markdown; charset=utf-8') + expect(response.headers.get('content-length')).toBe(String(Buffer.byteLength(content))) + expect(response.headers.get('content-disposition')).toContain('doc.md') + }) + it('rejects on declared asset bytes before downloading any of them', async () => { embeds('a', 'b', 'c') assetsResolveTo((id) => assetRecord(id, 100 * MB)) @@ -107,12 +166,26 @@ describe('markdown export bundling', () => { it('counts the document body against the export limit, not just its assets', async () => { // Assets alone sit under the cap; the body is what carries the bundle over it. embeds('a') - mockDownloadFile.mockResolvedValue(Buffer.alloc(250 * MB)) + mockDownloadFile.mockResolvedValue(Buffer.alloc(2 * MB)) + assetsResolveTo((id) => assetRecord(id, 249 * MB)) const response = await GET(request(), context) expect(response.status).toBe(400) expect((await response.json()).error).toContain('document and its embedded files') + expect(mockDownloadFile).toHaveBeenCalledTimes(1) + }) + + it('downloads large Markdown verbatim without parsing it or querying assets', async () => { + const content = Buffer.alloc(11 * MB, 'a') + mockDownloadFile.mockResolvedValue(content) + const response = await GET(request(), context) + expect(response.status).toBe(200) + expect(Buffer.from(await response.arrayBuffer()).equals(content)).toBe(true) + expect(response.headers.get('content-type')).toBe('text/markdown; charset=utf-8') + expect(mockExtractEmbeddedFileRefs).not.toHaveBeenCalled() + expect(mockGetFileMetadataById).toHaveBeenCalledTimes(1) + expect(mockDownloadFile).toHaveBeenCalledTimes(1) }) it('caps the document body read rather than loading it unbounded', async () => { @@ -148,6 +221,23 @@ describe('markdown export bundling', () => { expect(assetCall?.[0].maxBytes).toBe(25 * MB) }) + it('rejects actual aggregate bytes that exceed underreported asset metadata', async () => { + const ids = Array.from({ length: 30 }, (_, index) => `image-${index}`) + embeds(...ids) + assetsResolveTo((id) => assetRecord(id, 1)) + const asset = Buffer.alloc(25 * MB) + mockDownloadFile.mockImplementation(async ({ key }: { key: string }) => + key === DOC_RECORD.key ? Buffer.from('# Doc\n') : asset + ) + + const response = await GET(request(), context) + + expect(response.status).toBe(400) + expect((await response.json()).error).toContain('exceeds') + expect(recordAudit).not.toHaveBeenCalled() + expect(mockDownloadFile.mock.calls.length).toBeLessThan(ids.length + 1) + }) + it('drops an unreadable asset instead of failing the whole export', async () => { embeds('good', 'bad') mockDownloadFile.mockImplementation(async ({ key }: { key: string }) => { diff --git a/apps/sim/app/api/files/export/[id]/route.ts b/apps/sim/app/api/files/export/[id]/route.ts index 1d4b918e692..37737a8ba84 100644 --- a/apps/sim/app/api/files/export/[id]/route.ts +++ b/apps/sim/app/api/files/export/[id]/route.ts @@ -1,8 +1,6 @@ -import path from 'node:path' import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import JSZip from 'jszip' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { fileExportContract } from '@/lib/api/contracts/storage-transfer' @@ -16,6 +14,14 @@ import type { StorageContext } from '@/lib/uploads/config' import { getServeStoragePrefix } from '@/lib/uploads/config' import { downloadFile } from '@/lib/uploads/core/storage-service' import { extractEmbeddedFileRefs } from '@/lib/uploads/server/embedded-image-refs' +import { + createMarkdownExport, + MAX_EXPORT_MARKDOWN_PARSE_BYTES, + MAX_EXPORT_TOTAL_BYTES, + type MarkdownExportAsset, + type MarkdownExportResult, + MarkdownExportSizeError, +} from '@/lib/uploads/server/markdown-export' import { getFileMetadataById } from '@/lib/uploads/server/metadata' import { getWorkspaceFileSize } from '@/lib/uploads/shared/types' import { storedFileId } from '@/lib/uploads/utils/embedded-image-ref' @@ -25,18 +31,6 @@ import { encodeFilenameForHeader } from '@/app/api/files/utils' const logger = createLogger('FilesExportAPI') -/** - * Byte ceilings for a bundled export. The bytes behind an embed list are whatever the - * author put there, so without these the export would materialize unbounded assets in - * one request. They match the bulk-download route, so the two export surfaces reject at - * the same size. - * - * There is deliberately no count cap here: `extractEmbeddedFileRefs` already stops at - * `MAX_EMBEDDED_IMAGES`, so the list this route receives is bounded before it arrives. - */ -const MAX_EXPORT_ASSET_BYTES = 25 * 1024 * 1024 -const MAX_EXPORT_TOTAL_BYTES = 250 * 1024 * 1024 - const MARKDOWN_MIME_TYPES = new Set(['text/markdown', 'text/x-markdown']) const MARKDOWN_EXTENSIONS = new Set(['md', 'markdown']) @@ -46,22 +40,6 @@ function isMarkdown(originalName: string, contentType: string): boolean { return MARKDOWN_EXTENSIONS.has(ext) } -function safeFilename(name: string): string { - return path - .basename(name) - .replace(/["\\]/g, '_') - .replace(/[\r\n\t]/g, '') -} - -function deduplicatedFilename(preferred: string, existing: Set, imageId: string): string { - if (!existing.has(preferred)) return preferred - const ext = path.extname(preferred) - const base = path.basename(preferred, ext) - const short = `${base}_${imageId.slice(0, 8)}${ext}` - if (!existing.has(short)) return short - return `${base}_${imageId}${ext}` -} - export const GET = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { const parsed = await parseRequest(fileExportContract, request, context) @@ -152,11 +130,12 @@ export const GET = withRouteHandler( { status: 400 } ) } - let mdContent = mdBuffer.toString('utf-8') - // Ids only: a serve-URL embed names a storage key, which the bundler has no id to rewrite the // markdown against, so those images stay pointed at their original URL. - const { ids: imageIds } = extractEmbeddedFileRefs(mdContent) + const imageIds = + mdBuffer.length <= MAX_EXPORT_MARKDOWN_PARSE_BYTES + ? extractEmbeddedFileRefs(mdBuffer.toString('utf-8')).ids + : [] logger.info('Exporting markdown', { id, imageCount: imageIds.length }) @@ -174,106 +153,42 @@ export const GET = withRouteHandler( ) { return null } - return { imageId, record: imgRecord, size: getWorkspaceFileSize(imgRecord) } - } catch (error) { - logger.warn('Failed to resolve asset for export', { + return { imageId, - error: toError(error).message, - }) - return null - } - }) - ).filter((target): target is NonNullable => target !== null) - - // The body counts against the same budget as its assets — the zip holds both, so a - // limit that measured only the attachments would not describe the archive produced. - const bundleBytes = mdBuffer.length + assetTargets.reduce((sum, target) => sum + target.size, 0) - if (bundleBytes > MAX_EXPORT_TOTAL_BYTES) { - return NextResponse.json( - { - error: `This document and its embedded files total ${formatFileSize(bundleBytes)}, which exceeds the ${formatFileSize(MAX_EXPORT_TOTAL_BYTES)} export limit.`, - }, - { status: 400 } - ) - } - - const fetched = await mapWithConcurrency( - assetTargets, - MATERIALIZE_CONCURRENCY, - async ({ imageId, record: imgRecord }) => { - try { - const buffer = await downloadFile({ key: imgRecord.key, context: imgRecord.context as StorageContext, - maxBytes: MAX_EXPORT_ASSET_BYTES, - }) - return { imageId, originalName: imgRecord.originalName, buffer } + originalName: imgRecord.originalName, + size: getWorkspaceFileSize(imgRecord), + } satisfies MarkdownExportAsset } catch (error) { - // A single unreadable or oversized asset drops out of the bundle rather than - // failing the whole export; the markdown keeps its original link. - logger.warn('Failed to fetch asset for export', { + logger.warn('Failed to resolve asset for export', { imageId, error: toError(error).message, }) return null } - } - ) - - const assetMap = new Map() - const usedFilenames = new Set() - - for (const result of fetched) { - if (!result) continue - const { imageId, originalName, buffer } = result - const preferred = safeFilename(originalName) - const filename = deduplicatedFilename(preferred, usedFilenames, imageId) - usedFilenames.add(filename) - assetMap.set(imageId, { filename, buffer }) - } - - // Format follows what was bundled, not what was referenced: an embed can point at a file that is - // missing, unreadable, or oversized, and an empty `assets/` zip is a worse answer than the - // document itself. `mdContent` is still unrewritten here, so `mdBuffer` holds exactly its bytes. - if (assetMap.size === 0) { - auditExport('markdown', 0) - return new NextResponse(new Uint8Array(mdBuffer), { - status: 200, - headers: { - 'Content-Type': 'text/markdown; charset=utf-8', - 'Content-Disposition': `attachment; ${encodeFilenameForHeader(safeFilename(record.originalName))}`, - 'Content-Length': String(mdBuffer.length), - }, }) - } - - for (const [imageId, asset] of assetMap) { - const escapedId = imageId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - const replacement = `./assets/${asset.filename}` - // Rewrite both embed spellings the extractor resolves to this id — the view URL and the in-app - // `/workspace//files/` path — so a bundled asset never leaves a broken link in the export. - mdContent = mdContent - .replace(new RegExp(`/api/files/view/${escapedId}`, 'g'), () => replacement) - .replace(new RegExp(`/workspace/[A-Za-z0-9-]+/files/${escapedId}`, 'g'), () => replacement) - } + ).filter((target): target is NonNullable => target !== null) - const zip = new JSZip() - zip.file(safeFilename(record.originalName), mdContent) - const assetsFolder = zip.folder('assets')! - for (const { filename, buffer } of assetMap.values()) { - assetsFolder.file(filename, buffer) + let exported: MarkdownExportResult + try { + exported = await createMarkdownExport({ + content: mdBuffer, + fileName: record.originalName, + assets: assetTargets, + }) + } catch (error) { + if (!(error instanceof MarkdownExportSizeError)) throw error + return NextResponse.json({ error: error.message }, { status: 400 }) } - const zipBuffer = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }) - const zipName = safeFilename(`${record.originalName.replace(/\.[^.]+$/, '')}.zip`) - - auditExport('zip', assetMap.size) - return new NextResponse(new Uint8Array(zipBuffer), { + auditExport(exported.format, exported.assetCount) + return new NextResponse(new Uint8Array(exported.buffer), { status: 200, headers: { - 'Content-Type': 'application/zip', - 'Content-Disposition': `attachment; ${encodeFilenameForHeader(zipName)}`, - 'Content-Length': String(zipBuffer.length), + 'Content-Type': exported.contentType, + 'Content-Disposition': `attachment; ${encodeFilenameForHeader(exported.fileName)}`, + 'Content-Length': String(exported.buffer.length), }, }) } diff --git a/apps/sim/app/api/files/public/[token]/inline/route.test.ts b/apps/sim/app/api/files/public/[token]/inline/route.test.ts index f84e1298093..f0bfd6ba515 100644 --- a/apps/sim/app/api/files/public/[token]/inline/route.test.ts +++ b/apps/sim/app/api/files/public/[token]/inline/route.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { NextRequest } from 'next/server' +import { NextRequest, NextResponse } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' @@ -64,9 +64,29 @@ describe('GET /api/files/public/[token]/inline', () => { }) it('serves a same-workspace image referenced by the doc, typed from its bytes', async () => { - const res = await GET(req(`fileId=${FILE_ID}`), params) + const request = req(`fileId=${FILE_ID}`) + const res = await GET(request, params) expect(res.status).toBe(200) expect(res.headers.get('content-type')).toBe('image/png') + expect(mockRateLimit).toHaveBeenCalledExactlyOnceWith(request, 'inline') + expect(res.headers.get('cache-control')).toBe('private, no-cache, must-revalidate') + }) + + it('rejects exhausted image budgets before share lookup, authentication, or storage reads', async () => { + const limited = NextResponse.json( + { error: 'Too many requests. Please try again later.' }, + { status: 429, headers: { 'Retry-After': '60' } } + ) + mockRateLimit.mockResolvedValue(limited) + + const response = await GET(req(`fileId=${FILE_ID}`), params) + + expect(response.status).toBe(429) + expect(response.headers.get('Retry-After')).toBe('60') + expect(mockResolveShare).not.toHaveBeenCalled() + expect(mockValidateAuth).not.toHaveBeenCalled() + expect(mockResolveImage).not.toHaveBeenCalled() + expect(mockDownloadFile).not.toHaveBeenCalled() }) it('serves an image whose id is percent-encoded in the document', async () => { @@ -86,6 +106,33 @@ describe('GET /api/files/public/[token]/inline', () => { expect(res.status).toBe(200) }) + it.each(['fileId', 'key'] as const)( + 'serves a referenced %s beyond the export bundle limit', + async (kind) => { + const earlierImages = Array.from( + { length: 50 }, + (_, index) => `![earlier](/api/files/view/wf_earlier_${index})` + ) + const src = + kind === 'fileId' + ? `/api/files/view/${FILE_ID}` + : `/api/files/serve/${encodeURIComponent(IMG_KEY)}` + mockDownloadFile.mockImplementation( + downloadByKey([...earlierImages, `![last](${src})`].join('\n\n')) + ) + + const response = await GET( + req(`${kind}=${encodeURIComponent(kind === 'fileId' ? FILE_ID : IMG_KEY)}`), + params + ) + + expect(response.status).toBe(200) + expect(mockResolveImage).toHaveBeenCalledExactlyOnceWith('ws-1', { + [kind]: kind === 'fileId' ? FILE_ID : IMG_KEY, + }) + } + ) + it('404s when the reference is not embedded in the shared document', async () => { mockDownloadFile.mockImplementation(downloadByKey('no images here')) const res = await GET(req(`fileId=${FILE_ID}`), params) @@ -93,6 +140,24 @@ describe('GET /api/files/public/[token]/inline', () => { expect(mockResolveImage).not.toHaveBeenCalled() }) + it.each([ + `[link](/api/files/view/${FILE_ID})`, + `\`![image](/api/files/view/${FILE_ID})\``, + ``, + ``, + `

`, + `inline text`, + `![external](https://example.com/api/files/view/${FILE_ID})`, + ])('does not extend a share to an image mentioned as %s', async (source) => { + mockDownloadFile.mockImplementation(downloadByKey(source)) + + const response = await GET(req(`fileId=${FILE_ID}`), params) + + expect(response.status).toBe(404) + expect(mockResolveImage).not.toHaveBeenCalled() + expect(mockDownloadFile).toHaveBeenCalledTimes(1) + }) + it('404s when the referenced file is not in the document workspace', async () => { mockResolveImage.mockResolvedValue(null) const res = await GET(req(`fileId=${FILE_ID}`), params) diff --git a/apps/sim/app/api/files/public/[token]/inline/route.ts b/apps/sim/app/api/files/public/[token]/inline/route.ts index 69c09db12b1..77e52efe587 100644 --- a/apps/sim/app/api/files/public/[token]/inline/route.ts +++ b/apps/sim/app/api/files/public/[token]/inline/route.ts @@ -11,9 +11,8 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit' import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager' import { downloadFile } from '@/lib/uploads/core/storage-service' -import { extractEmbeddedFileRefs } from '@/lib/uploads/server/embedded-image-refs' +import { hasEmbeddedFileRef } from '@/lib/uploads/server/embedded-image-refs' import { resolveWorkspaceInlineImage } from '@/lib/uploads/server/inline-image' -import { storedFileId } from '@/lib/uploads/utils/embedded-image-ref' import { serveInlineImage } from '@/app/api/files/serve-inline-image' import { createErrorResponse, FileNotFoundError } from '@/app/api/files/utils' @@ -54,7 +53,7 @@ export const GET = withRouteHandler( const requestId = generateRequestId() try { - const limited = await enforcePublicFileRateLimit(request, 'content') + const limited = await enforcePublicFileRateLimit(request, 'inline') if (limited) return limited const parsed = await parseRequest(getPublicInlineFileContract, request, context) @@ -99,11 +98,8 @@ export const GET = withRouteHandler( logger.info('Shared document too large to scan for embedded references', { token }) throw new FileNotFoundError('Not found') } - const { keys, ids } = extractEmbeddedFileRefs(docText) - const referenced = ref.fileId - ? ids.some((id) => storedFileId(id) === ref.fileId) - : keys.includes(ref.key as string) - if (!referenced) { + const target = ref.fileId ? { fileId: ref.fileId } : ref.key ? { key: ref.key } : null + if (!target || !hasEmbeddedFileRef(docText, target)) { throw new FileNotFoundError('Not found') } diff --git a/apps/sim/app/api/files/uploads/purposes.ts b/apps/sim/app/api/files/uploads/purposes.ts index 38982824cf7..458c1658863 100644 --- a/apps/sim/app/api/files/uploads/purposes.ts +++ b/apps/sim/app/api/files/uploads/purposes.ts @@ -237,6 +237,7 @@ async function principalUserId(principal: Principal, workspaceId?: string): Prom switch (principal.kind) { case 'session': case 'personal_api_key': + case 'oauth_access_token': return principal.userId case 'workspace_api_key': if (!workspaceId || principal.workspaceId !== workspaceId) { @@ -253,11 +254,17 @@ async function principalUserId(principal: Principal, workspaceId?: string): Prom throw new UploadSessionError('forbidden', 'Delegated principals cannot create uploads') case 'system': throw new UploadSessionError('forbidden', 'System principals cannot create uploads') + case 'organization_delegated': case 'credential_group_enrollment': throw new UploadSessionError( 'forbidden', 'Credential Group enrollment principals cannot create uploads' ) + case 'scim_connection': + throw new UploadSessionError( + 'forbidden', + 'Directory provisioning credentials cannot create uploads' + ) } } diff --git a/apps/sim/app/api/files/utils.test.ts b/apps/sim/app/api/files/utils.test.ts index b495df731d5..7a01716b04e 100644 --- a/apps/sim/app/api/files/utils.test.ts +++ b/apps/sim/app/api/files/utils.test.ts @@ -178,6 +178,15 @@ describe('extractFilename', () => { expect(response.headers.get('Content-Security-Policy')).toBeNull() }) + it('appends the content-type extension to an extensionless download name', () => { + const response = createFileResponse({ + buffer: Buffer.from('fake-image-data'), + contentType: 'image/png', + filename: 'navbar_2', + }) + expect(response.headers.get('Content-Disposition')).toBe('inline; filename="navbar_2.png"') + }) + it('defaults to a PRIVATE cache so access-verified content is never shared-cached', () => { const response = createFileResponse({ buffer: Buffer.from('fake-image-data'), diff --git a/apps/sim/app/api/files/utils.ts b/apps/sim/app/api/files/utils.ts index c74679197b0..c7bd642db8e 100644 --- a/apps/sim/app/api/files/utils.ts +++ b/apps/sim/app/api/files/utils.ts @@ -4,7 +4,7 @@ import { isPayloadSizeLimitError, readNodeStreamToBufferWithLimit, } from '@/lib/core/utils/stream-limits' -import { sanitizeFileKey } from '@/lib/uploads/utils/file-utils' +import { ensureFileNameExtension, sanitizeFileKey } from '@/lib/uploads/utils/file-utils' const logger = createLogger('FilesUtils') @@ -240,16 +240,13 @@ export function encodeFilenameForHeader(storageKey: string): string { return `filename="${asciiSafe}"; filename*=UTF-8''${encodeExtValue(filename)}` } +/** + * Derives the served filename from the CALLER's content type (`getSecureFileHeaders` + * downgrades `text/html`) before the header decision, so a derived `.html` name gets the + * same forced-attachment treatment a stored `.html` file gets. + */ export function createFileResponse(file: FileResponse): NextResponse { - // Sim pages store an extensionless name and serve/download as compiled - // HTML — re-append the extension so the saved file opens in a browser. - // Decided from the CALLER's content type (getSecureFileHeaders downgrades - // text/html), and BEFORE the header decision, so the .html name gets the - // same forced-attachment treatment a legacy .html file gets. - const servedFilename = - file.contentType === 'text/html' && !/\.[A-Za-z0-9]{1,8}$/.test(file.filename) - ? `${file.filename}.html` - : file.filename + const servedFilename = ensureFileNameExtension(file.filename, file.contentType) const { contentType, disposition } = getSecureFileHeaders(servedFilename, file.contentType) diff --git a/apps/sim/app/api/internal/file-doc/seed/route.test.ts b/apps/sim/app/api/internal/file-doc/seed/route.test.ts index f64504f9ded..2f071395033 100644 --- a/apps/sim/app/api/internal/file-doc/seed/route.test.ts +++ b/apps/sim/app/api/internal/file-doc/seed/route.test.ts @@ -19,7 +19,7 @@ vi.mock('@/lib/collab-doc/seed', () => ({ buildFileDocSeed: mockBuildFileDocSeed, })) -import { POST } from './route' +import { POST } from '@/app/api/internal/file-doc/seed/route' function seedRequest(body: unknown) { return createMockRequest('POST', body, { 'x-api-key': 'internal' }) @@ -31,9 +31,7 @@ describe('POST /api/internal/file-doc/seed', () => { mockCheckInternalApiKey.mockReturnValue({ success: true }) }) - // Regression guard for the auth-helper choice: the realtime relay authenticates with - // `x-api-key: INTERNAL_API_SECRET`, so this route MUST gate on `checkInternalApiKey`. Wiring the - // Bearer-JWT-only `checkInternalAuth` (which forbids `x-api-key`) 401s every real seed fetch. + /** The relay authenticates with the shared internal API key, not a Bearer JWT. */ it('401s when the internal api key is rejected, without building a seed', async () => { mockCheckInternalApiKey.mockReturnValue({ success: false }) const res = await POST(seedRequest({ workspaceId: 'ws-1', fileId: 'file-1' })) @@ -43,10 +41,11 @@ describe('POST /api/internal/file-doc/seed', () => { it('returns the seed as base64 for an authorized request', async () => { mockBuildFileDocSeed.mockResolvedValue({ update: new Uint8Array([1, 2, 3, 4]) }) - const res = await POST(seedRequest({ workspaceId: 'ws-1', fileId: 'file-1' })) + const request = seedRequest({ workspaceId: 'ws-1', fileId: 'file-1' }) + const res = await POST(request) expect(res.status).toBe(200) expect((await res.json()).update).toBe(Buffer.from([1, 2, 3, 4]).toString('base64')) - expect(mockBuildFileDocSeed).toHaveBeenCalledWith('ws-1', 'file-1') + expect(mockBuildFileDocSeed).toHaveBeenCalledWith('ws-1', 'file-1', request.signal) }) it('returns update:null for a genuinely absent file', async () => { diff --git a/apps/sim/app/api/internal/file-doc/seed/route.ts b/apps/sim/app/api/internal/file-doc/seed/route.ts index aaf051da094..75cc13f3864 100644 --- a/apps/sim/app/api/internal/file-doc/seed/route.ts +++ b/apps/sim/app/api/internal/file-doc/seed/route.ts @@ -25,7 +25,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const { workspaceId, fileId } = parsed.data.body try { - const seed = await buildFileDocSeed(workspaceId, fileId) + const seed = await buildFileDocSeed(workspaceId, fileId, request.signal) return NextResponse.json({ update: seed ? Buffer.from(seed.update).toString('base64') : null, version: seed ? seed.version : null, diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/access/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/access/route.ts index ab89571ab7e..af895bf5a0d 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/access/route.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/access/route.ts @@ -24,8 +24,6 @@ export const PATCH = defineInternalJsonRoute({ connectorId: params.connectorId, knowledgeBaseId: params.id, accessMode: body.accessMode, - credentialGroupId: body.credentialGroupId, - credentialGroupOptionId: body.credentialGroupOptionId, credentialId: body.credentialId, resolveBillingAttribution: (workspaceId: string) => resolveInternalKnowledgeBillingAttribution(request, principal, workspaceId), diff --git a/apps/sim/app/api/knowledge/[id]/connectors/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/route.ts index 28199775a22..10eeff6ea5a 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/route.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/route.ts @@ -50,8 +50,6 @@ export const POST = defineInternalJsonRoute({ sourceConfig: body.sourceConfig, syncIntervalMinutes: body.syncIntervalMinutes, accessMode: body.accessMode, - credentialGroupId: body.credentialGroupId, - credentialGroupOptionId: body.credentialGroupOptionId, resolveBillingAttribution: (workspaceId: string) => resolveInternalKnowledgeBillingAttribution(request, principal, workspaceId), source: 'ui' as const, diff --git a/apps/sim/app/api/knowledge/connectors/directory-sync/route.test.ts b/apps/sim/app/api/knowledge/connectors/directory-sync/route.test.ts new file mode 100644 index 00000000000..f9b396d2cda --- /dev/null +++ b/apps/sim/app/api/knowledge/connectors/directory-sync/route.test.ts @@ -0,0 +1,153 @@ +/** + * @vitest-environment node + */ +import { + createMockRequest, + flattenMockConditions, + hasMockCondition, + schemaMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockVerifyCronAuth, mockConnectorRows, mockDispatch, mockClaim, mockWhere } = vi.hoisted( + () => ({ + mockVerifyCronAuth: vi.fn(() => null), + mockConnectorRows: vi.fn(), + mockDispatch: vi.fn(), + mockClaim: vi.fn(), + mockWhere: vi.fn(), + }) +) + +vi.mock('@/lib/auth/internal', () => ({ verifyCronAuth: mockVerifyCronAuth })) +vi.mock('@/lib/knowledge/connectors/directory-queue', () => ({ + dispatchDirectorySync: mockDispatch, +})) +vi.mock('@sim/db', () => ({ + db: { + update: () => ({ set: () => ({ where: () => ({ returning: () => mockClaim() }) }) }), + select: () => ({ + from: () => ({ + innerJoin: () => ({ + where: (condition: unknown) => { + mockWhere(condition) + return { orderBy: () => ({ limit: () => mockConnectorRows() }) } + }, + }), + }), + }), + }, +})) + +import { GET } from '@/app/api/knowledge/connectors/directory-sync/route' + +function connector(overrides: Record = {}) { + return { id: 'connector-1', nextDirectorySyncAt: new Date(0), ...overrides } +} + +async function run() { + const response = await GET(createMockRequest('GET')) + return response.json() +} + +describe('connector directory sync scheduler', () => { + beforeEach(() => { + vi.clearAllMocks() + mockVerifyCronAuth.mockReturnValue(null) + mockDispatch.mockResolvedValue(undefined) + mockClaim.mockResolvedValue([{ id: 'connector-1' }]) + }) + + /** + * Every eligible connector is offered under one tick time; the tenant-level + * freshness check in the refresh, not the scheduler, decides which walk. + */ + it('dispatches a refresh for every admin-mode connector under the same tick', async () => { + mockConnectorRows.mockResolvedValue([connector(), connector({ id: 'connector-2' })]) + + await expect(run()).resolves.toMatchObject({ considered: 2, dispatched: 2, failed: 0 }) + expect(mockDispatch).toHaveBeenCalledTimes(2) + const [, first] = mockDispatch.mock.calls[0] + const [, second] = mockDispatch.mock.calls[1] + expect(first.tickAt).toBe(second.tickAt) + }) + + it('includes either canonical owner while retaining mirrored-source eligibility', async () => { + mockConnectorRows.mockResolvedValue([connector({ id: 'org-source' })]) + await run() + const condition = mockWhere.mock.calls[0][0] + const ownerChoice = flattenMockConditions(condition).find((entry) => entry.type === 'or') + expect(ownerChoice).toBeDefined() + expect(ownerChoice?.conditions).toHaveLength(2) + const [workspaceOwner, organizationOwner] = Array.isArray(ownerChoice?.conditions) + ? ownerChoice.conditions + : [] + expect( + hasMockCondition( + workspaceOwner, + (node) => node.type === 'isNotNull' && node.column === schemaMock.knowledgeBase.workspaceId + ) + ).toBe(true) + expect( + hasMockCondition( + workspaceOwner, + (node) => node.type === 'isNull' && node.column === schemaMock.knowledgeBase.organizationId + ) + ).toBe(true) + expect( + hasMockCondition( + organizationOwner, + (node) => node.type === 'isNull' && node.column === schemaMock.knowledgeBase.workspaceId + ) + ).toBe(true) + expect( + hasMockCondition( + organizationOwner, + (node) => + node.type === 'isNotNull' && node.column === schemaMock.knowledgeBase.organizationId + ) + ).toBe(true) + expect( + hasMockCondition( + condition, + (node) => node.type === 'isNull' && node.column === schemaMock.knowledgeConnector.archivedAt + ) + ).toBe(true) + expect( + hasMockCondition( + condition, + (node) => node.type === 'isNull' && node.column === schemaMock.knowledgeConnector.deletedAt + ) + ).toBe(true) + expect( + hasMockCondition( + condition, + (node) => node.type === 'isNull' && node.column === schemaMock.knowledgeBase.deletedAt + ) + ).toBe(true) + expect(mockDispatch).toHaveBeenCalledExactlyOnceWith('org-source', expect.anything()) + }) + + it('contains a dispatch failure to the connector that caused it', async () => { + mockConnectorRows.mockResolvedValue([connector(), connector({ id: 'connector-2' })]) + mockDispatch.mockRejectedValueOnce(new Error('queue unreachable')) + + await expect(run()).resolves.toMatchObject({ dispatched: 1, failed: 1 }) + }) + + it('does not enqueue a connector another scheduler claimed or paused', async () => { + mockConnectorRows.mockResolvedValue([connector()]) + mockClaim.mockResolvedValueOnce([]) + await expect(run()).resolves.toMatchObject({ considered: 1, dispatched: 0, failed: 0 }) + expect(mockDispatch).not.toHaveBeenCalled() + }) + + it('refuses an unauthenticated tick', async () => { + mockVerifyCronAuth.mockReturnValue(new Response('nope', { status: 401 })) + + const response = await GET(createMockRequest('GET')) + + expect(response.status).toBe(401) + expect(mockConnectorRows).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/knowledge/connectors/directory-sync/route.ts b/apps/sim/app/api/knowledge/connectors/directory-sync/route.ts new file mode 100644 index 00000000000..9867a9823cd --- /dev/null +++ b/apps/sim/app/api/knowledge/connectors/directory-sync/route.ts @@ -0,0 +1,89 @@ +import { db } from '@sim/db' +import { knowledgeBase, knowledgeConnector } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { and, asc, eq, inArray, isNotNull, isNull, lte, or } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { verifyCronAuth } from '@/lib/auth/internal' +import { mapWithConcurrency } from '@/lib/core/utils/concurrency' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { EXTERNAL_GROUP_SYNC_INTERVAL_MS } from '@/lib/knowledge/access/external-groups' +import { MIRRORING_ACCESS_MODES } from '@/lib/knowledge/connectors/access-modes' +import { dispatchDirectorySync } from '@/lib/knowledge/connectors/directory-queue' +import { RUNNABLE_CONNECTOR_STATUSES } from '@/lib/knowledge/connectors/sync-lock' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('ConnectorDirectorySyncSchedulerAPI') + +/** Connectors offered per tick, and how many dispatches are in flight at once. */ +const MAX_DIRECTORIES_PER_TICK = 200 +const DISPATCH_CONCURRENCY = 8 + +/** Offers the oldest due directories first; successful claims advance across bounded ticks. */ +export const GET = withRouteHandler(async (request: NextRequest) => { + const requestId = generateRequestId() + const tickAt = new Date() + logger.info('Connector directory sync scheduler triggered') + + const authError = verifyCronAuth(request, 'Connector directory sync scheduler') + if (authError) return authError + + const connectors = await db + .select({ id: knowledgeConnector.id }) + .from(knowledgeConnector) + .innerJoin(knowledgeBase, eq(knowledgeConnector.knowledgeBaseId, knowledgeBase.id)) + .where( + and( + inArray(knowledgeConnector.accessMode, MIRRORING_ACCESS_MODES), + lte(knowledgeConnector.nextDirectorySyncAt, tickAt), + inArray(knowledgeConnector.status, RUNNABLE_CONNECTOR_STATUSES), + isNull(knowledgeConnector.archivedAt), + isNull(knowledgeConnector.deletedAt), + isNull(knowledgeBase.deletedAt), + or( + and(isNotNull(knowledgeBase.workspaceId), isNull(knowledgeBase.organizationId)), + and(isNull(knowledgeBase.workspaceId), isNotNull(knowledgeBase.organizationId)) + ) + ) + ) + .orderBy(asc(knowledgeConnector.nextDirectorySyncAt), asc(knowledgeConnector.id)) + .limit(MAX_DIRECTORIES_PER_TICK) + + let dispatched = 0 + let failed = 0 + await mapWithConcurrency(connectors, DISPATCH_CONCURRENCY, async ({ id: connectorId }) => { + try { + const claimed = await db + .update(knowledgeConnector) + .set({ + nextDirectorySyncAt: new Date(tickAt.getTime() + EXTERNAL_GROUP_SYNC_INTERVAL_MS), + }) + .where( + and( + eq(knowledgeConnector.id, connectorId), + lte(knowledgeConnector.nextDirectorySyncAt, tickAt), + inArray(knowledgeConnector.accessMode, MIRRORING_ACCESS_MODES), + inArray(knowledgeConnector.status, RUNNABLE_CONNECTOR_STATUSES), + isNull(knowledgeConnector.archivedAt), + isNull(knowledgeConnector.deletedAt) + ) + ) + .returning({ id: knowledgeConnector.id }) + if (!claimed.length) return + await dispatchDirectorySync(connectorId, { requestId, tickAt }) + dispatched += 1 + } catch (error) { + failed += 1 + logger.error('Failed to dispatch a directory refresh', { + connectorId, + error: getErrorMessage(error), + }) + } + }) + + const summary = { considered: connectors.length, dispatched, failed } + logger.info('Connector directory sync scheduler finished', summary) + return Response.json({ success: true, ...summary }) +}) diff --git a/apps/sim/app/api/knowledge/connectors/member-sync/route.test.ts b/apps/sim/app/api/knowledge/connectors/member-sync/route.test.ts new file mode 100644 index 00000000000..c045b61d8c9 --- /dev/null +++ b/apps/sim/app/api/knowledge/connectors/member-sync/route.test.ts @@ -0,0 +1,132 @@ +/** @vitest-environment node */ +import { + createMockRequest, + dbChainMockFns, + hasMockCondition, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + auth: vi.fn(), + dispatch: vi.fn(), + workspaceBilling: vi.fn(), + organizationBilling: vi.fn(), + sweep: vi.fn(), +})) +vi.mock('@/lib/auth/internal', () => ({ verifyCronAuth: mocks.auth })) +vi.mock('@/lib/billing/core/billing-attribution', () => ({ + resolveSystemBillingAttribution: mocks.workspaceBilling, + resolveSystemOrganizationBillingAttribution: mocks.organizationBilling, +})) +vi.mock('@/lib/knowledge/connectors/member-queue', () => ({ + dispatchMemberSync: mocks.dispatch, + QUEUEABLE_MEMBER_SYNC_STATUSES: ['idle', 'error'], +})) +vi.mock('@/lib/knowledge/connectors/member-observations', () => ({ + sweepStaleMemberObservations: mocks.sweep, +})) + +import { GET } from '@/app/api/knowledge/connectors/member-sync/route' + +beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.auth.mockReturnValue(null) + mocks.dispatch.mockResolvedValue(undefined) + mocks.sweep.mockResolvedValue({ members: 0 }) + mocks.workspaceBilling.mockResolvedValue({ workspaceId: 'workspace-a' }) + mocks.organizationBilling.mockResolvedValue({ workspaceId: null, organizationId: 'org-a' }) +}) + +describe('member sync scheduler owner routing', () => { + it('does not read or dispatch without cron authentication', async () => { + mocks.auth.mockReturnValue(new Response('Unauthorized', { status: 401 })) + const response = await GET(createMockRequest('GET')) + expect(response.status).toBe(401) + expect(dbChainMockFns.select).not.toHaveBeenCalled() + expect(dbChainMockFns.update).not.toHaveBeenCalled() + expect(mocks.dispatch).not.toHaveBeenCalled() + }) + + it('projects org ownership and dispatches with its actual system payer', async () => { + const nextMemberSyncAt = new Date('2026-09-01T00:00:00Z') + queueTableRows(schemaMock.knowledgeConnector, [ + { id: 'org-source', workspaceId: null, organizationId: 'org-a', nextMemberSyncAt }, + ]) + await GET(createMockRequest('GET')) + expect(dbChainMockFns.select).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: schemaMock.knowledgeBase.workspaceId, + organizationId: schemaMock.knowledgeBase.organizationId, + }) + ) + expect(mocks.organizationBilling).toHaveBeenCalledExactlyOnceWith('org-a') + expect(mocks.workspaceBilling).not.toHaveBeenCalled() + expect(mocks.dispatch).toHaveBeenCalledExactlyOnceWith('org-source', { + billingAttribution: { workspaceId: null, organizationId: 'org-a' }, + expectedNextMemberSyncAt: nextMemberSyncAt, + requestId: expect.any(String), + requireRunnable: true, + }) + const where = dbChainMockFns.where.mock.calls.at(-1)?.[0] + expect( + hasMockCondition( + where, + (node) => + node.type === 'eq' && + node.left === schemaMock.knowledgeConnector.accessMode && + node.right === 'members' + ) + ).toBe(true) + expect( + hasMockCondition( + where, + (node) => node.type === 'isNull' && node.column === schemaMock.knowledgeConnector.archivedAt + ) + ).toBe(true) + expect( + hasMockCondition( + where, + (node) => node.type === 'isNull' && node.column === schemaMock.knowledgeConnector.deletedAt + ) + ).toBe(true) + expect( + hasMockCondition( + where, + (node) => node.type === 'isNull' && node.column === schemaMock.knowledgeBase.deletedAt + ) + ).toBe(true) + }) + + it('preserves workspace dispatch and refuses absent or ambiguous ownership', async () => { + queueTableRows(schemaMock.knowledgeConnector, [ + { id: 'missing', workspaceId: null, organizationId: null }, + { id: 'ambiguous', workspaceId: 'workspace-a', organizationId: 'org-a' }, + { id: 'workspace-source', workspaceId: 'workspace-a', organizationId: null }, + ]) + await GET(createMockRequest('GET')) + expect(mocks.organizationBilling).not.toHaveBeenCalled() + expect(mocks.workspaceBilling).toHaveBeenCalledExactlyOnceWith('workspace-a') + expect(mocks.dispatch).toHaveBeenCalledExactlyOnceWith( + 'workspace-source', + expect.objectContaining({ + billingAttribution: { workspaceId: 'workspace-a' }, + requireRunnable: true, + }) + ) + }) + + it('does not enqueue an org source when its payer cannot be resolved', async () => { + queueTableRows(schemaMock.knowledgeConnector, [ + { id: 'org-source', workspaceId: null, organizationId: 'org-a' }, + ]) + mocks.organizationBilling.mockRejectedValue(new Error('Organization payer unavailable')) + const response = await GET(createMockRequest('GET')) + expect(response.status).toBe(200) + expect(mocks.dispatch).not.toHaveBeenCalled() + expect(mocks.workspaceBilling).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/knowledge/connectors/member-sync/route.ts b/apps/sim/app/api/knowledge/connectors/member-sync/route.ts index 076344f81dd..10088e0cb14 100644 --- a/apps/sim/app/api/knowledge/connectors/member-sync/route.ts +++ b/apps/sim/app/api/knowledge/connectors/member-sync/route.ts @@ -4,7 +4,11 @@ import { createLogger } from '@sim/logger' import { and, asc, eq, inArray, isNull, lte, type SQL, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { verifyCronAuth } from '@/lib/auth/internal' -import { resolveSystemBillingAttribution } from '@/lib/billing/core/billing-attribution' +import { + resolveSystemBillingAttribution, + resolveSystemOrganizationBillingAttribution, +} from '@/lib/billing/core/billing-attribution' +import { resourceScopeFromOwner } from '@/lib/core/resource-scope' import { mapWithConcurrency } from '@/lib/core/utils/concurrency' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -20,6 +24,7 @@ import { MAX_CONSECUTIVE_FAILURES, MEMBER_SYNC_STALE_LOCK_TTL_MS, } from '@/lib/knowledge/connectors/sync-limits' +import { RUNNABLE_CONNECTOR_STATUSES } from '@/lib/knowledge/connectors/sync-lock' export const dynamic = 'force-dynamic' @@ -162,13 +167,14 @@ export const GET = withRouteHandler(async (request: NextRequest) => { id: knowledgeConnector.id, nextMemberSyncAt: knowledgeConnector.nextMemberSyncAt, workspaceId: knowledgeBase.workspaceId, + organizationId: knowledgeBase.organizationId, }) .from(knowledgeConnector) .innerJoin(knowledgeBase, eq(knowledgeConnector.knowledgeBaseId, knowledgeBase.id)) .where( and( eq(knowledgeConnector.accessMode, 'members'), - inArray(knowledgeConnector.status, ['active', 'error']), + inArray(knowledgeConnector.status, RUNNABLE_CONNECTOR_STATUSES), inArray(knowledgeConnector.memberSyncStatus, QUEUEABLE_MEMBER_SYNC_STATUSES), lte(knowledgeConnector.nextMemberSyncAt, now), isNull(knowledgeConnector.archivedAt), @@ -191,10 +197,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => { await mapWithConcurrency(dueConnectors, DISPATCH_CONCURRENCY, async (connector) => { try { - if (!connector.workspaceId) { - throw new Error(`Connector ${connector.id} is missing workspace billing context`) - } - const billingAttribution = await resolveSystemBillingAttribution(connector.workspaceId) + const scope = resourceScopeFromOwner(connector) + const billingAttribution = + scope.kind === 'organization' + ? await resolveSystemOrganizationBillingAttribution(scope.organizationId) + : await resolveSystemBillingAttribution(scope.workspaceId) await dispatchMemberSync(connector.id, { billingAttribution, expectedNextMemberSyncAt: connector.nextMemberSyncAt ?? undefined, diff --git a/apps/sim/app/api/knowledge/connectors/sync/route.test.ts b/apps/sim/app/api/knowledge/connectors/sync/route.test.ts index 2c8a6c2982b..1dd99647867 100644 --- a/apps/sim/app/api/knowledge/connectors/sync/route.test.ts +++ b/apps/sim/app/api/knowledge/connectors/sync/route.test.ts @@ -27,18 +27,25 @@ import { MAX_CONSECUTIVE_FAILURES, } from '@/lib/knowledge/connectors/sync-limits' -const { mockVerifyCronAuth, mockDispatchSync, mockResolveSystemBillingAttribution } = vi.hoisted( - () => ({ - mockVerifyCronAuth: vi.fn().mockReturnValue(null), - mockDispatchSync: vi.fn().mockResolvedValue(undefined), - mockResolveSystemBillingAttribution: vi.fn().mockResolvedValue({ workspaceId: 'ws-1' }), - }) -) +const { + mockVerifyCronAuth, + mockDispatchSync, + mockResolveSystemBillingAttribution, + mockResolveSystemOrganizationBillingAttribution, +} = vi.hoisted(() => ({ + mockVerifyCronAuth: vi.fn().mockReturnValue(null), + mockDispatchSync: vi.fn().mockResolvedValue(undefined), + mockResolveSystemBillingAttribution: vi.fn().mockResolvedValue({ workspaceId: 'ws-1' }), + mockResolveSystemOrganizationBillingAttribution: vi + .fn() + .mockResolvedValue({ workspaceId: null, organizationId: 'org-1' }), +})) vi.mock('@/lib/auth/internal', () => ({ verifyCronAuth: mockVerifyCronAuth })) vi.mock('@/lib/knowledge/connectors/queue', () => ({ dispatchSync: mockDispatchSync })) vi.mock('@/lib/billing/core/billing-attribution', () => ({ resolveSystemBillingAttribution: mockResolveSystemBillingAttribution, + resolveSystemOrganizationBillingAttribution: mockResolveSystemOrganizationBillingAttribution, })) import { GET } from '@/app/api/knowledge/connectors/sync/route' @@ -131,6 +138,10 @@ beforeEach(() => { mockVerifyCronAuth.mockReturnValue(null) mockDispatchSync.mockResolvedValue(undefined) mockResolveSystemBillingAttribution.mockResolvedValue({ workspaceId: 'ws-1' }) + mockResolveSystemOrganizationBillingAttribution.mockResolvedValue({ + workspaceId: null, + organizationId: 'org-1', + }) vi.useFakeTimers() vi.setSystemTime(NOW) }) @@ -550,7 +561,7 @@ describe('connector sync scheduler authentication and dispatch', () => { ) }) - it('skips a connector missing workspace billing context without failing the tick', async () => { + it('skips a connector missing resource billing context without failing the tick', async () => { queueTableRows(schemaMock.knowledgeConnector, [ { id: 'due-1', workspaceId: null }, { id: 'due-2', workspaceId: 'ws-2' }, @@ -563,6 +574,42 @@ describe('connector sync scheduler authentication and dispatch', () => { expect(mockDispatchSync).toHaveBeenCalledWith('due-2', expect.anything()) }) + it('dispatches org sources with their canonical organization payer', async () => { + const nextSyncAt = new Date('2026-09-01T00:00:00Z') + queueTableRows(schemaMock.knowledgeConnector, [ + { id: 'org-source', workspaceId: null, organizationId: 'org-1', nextSyncAt }, + ]) + await GET(cronRequest()) + expect(dbChainMockFns.select).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: schemaMock.knowledgeBase.workspaceId, + organizationId: schemaMock.knowledgeBase.organizationId, + }) + ) + expect(mockResolveSystemBillingAttribution).not.toHaveBeenCalled() + expect(mockResolveSystemOrganizationBillingAttribution).toHaveBeenCalledExactlyOnceWith('org-1') + expect(mockDispatchSync).toHaveBeenCalledExactlyOnceWith('org-source', { + billingAttribution: { workspaceId: null, organizationId: 'org-1' }, + expectedNextSyncAt: nextSyncAt, + requestId: expect.any(String), + requireRunnable: true, + }) + }) + + it('does not infer a payer for ambiguous ownership or failed org billing', async () => { + queueTableRows(schemaMock.knowledgeConnector, [ + { id: 'ambiguous', workspaceId: 'ws-1', organizationId: 'org-1' }, + { id: 'org-source', workspaceId: null, organizationId: 'org-1' }, + ]) + mockResolveSystemOrganizationBillingAttribution.mockRejectedValue( + new Error('Owner unavailable') + ) + await GET(cronRequest()) + expect(mockResolveSystemBillingAttribution).not.toHaveBeenCalled() + expect(mockResolveSystemOrganizationBillingAttribution).toHaveBeenCalledExactlyOnceWith('org-1') + expect(mockDispatchSync).not.toHaveBeenCalled() + }) + it('reports a tick with nothing due', async () => { const response = await GET(cronRequest()) diff --git a/apps/sim/app/api/knowledge/connectors/sync/route.ts b/apps/sim/app/api/knowledge/connectors/sync/route.ts index 7d3d5afcbd6..e28e8561358 100644 --- a/apps/sim/app/api/knowledge/connectors/sync/route.ts +++ b/apps/sim/app/api/knowledge/connectors/sync/route.ts @@ -4,10 +4,15 @@ import { createLogger } from '@sim/logger' import { and, asc, eq, inArray, isNull, lte, type SQL, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { verifyCronAuth } from '@/lib/auth/internal' -import { resolveSystemBillingAttribution } from '@/lib/billing/core/billing-attribution' +import { + resolveSystemBillingAttribution, + resolveSystemOrganizationBillingAttribution, +} from '@/lib/billing/core/billing-attribution' +import { resourceScopeFromOwner } from '@/lib/core/resource-scope' import { mapWithConcurrency } from '@/lib/core/utils/concurrency' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { CONTENT_ENGINE_ACCESS_MODES } from '@/lib/knowledge/connectors/access-modes' import { dispatchSync } from '@/lib/knowledge/connectors/queue' import { CONNECTOR_AUTO_DISABLED_ERROR, @@ -16,6 +21,7 @@ import { CONNECTOR_SYNC_STALE_LOCK_TTL_MS, MAX_CONSECUTIVE_FAILURES, } from '@/lib/knowledge/connectors/sync-limits' +import { RUNNABLE_CONNECTOR_STATUSES } from '@/lib/knowledge/connectors/sync-lock' export const dynamic = 'force-dynamic' @@ -298,13 +304,14 @@ export const GET = withRouteHandler(async (request: NextRequest) => { id: knowledgeConnector.id, nextSyncAt: knowledgeConnector.nextSyncAt, workspaceId: knowledgeBase.workspaceId, + organizationId: knowledgeBase.organizationId, }) .from(knowledgeConnector) .innerJoin(knowledgeBase, eq(knowledgeConnector.knowledgeBaseId, knowledgeBase.id)) .where( and( - inArray(knowledgeConnector.status, ['active', 'error']), - eq(knowledgeConnector.accessMode, 'workspace'), + inArray(knowledgeConnector.status, RUNNABLE_CONNECTOR_STATUSES), + inArray(knowledgeConnector.accessMode, CONTENT_ENGINE_ACCESS_MODES), lte(knowledgeConnector.nextSyncAt, now), isNull(knowledgeConnector.archivedAt), isNull(knowledgeConnector.deletedAt), @@ -326,10 +333,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => { await mapWithConcurrency(dueConnectors, DISPATCH_CONCURRENCY, async (connector) => { try { - if (!connector.workspaceId) { - throw new Error(`Connector ${connector.id} is missing workspace billing context`) - } - const billingAttribution = await resolveSystemBillingAttribution(connector.workspaceId) + const scope = resourceScopeFromOwner(connector) + const billingAttribution = + scope.kind === 'organization' + ? await resolveSystemOrganizationBillingAttribution(scope.organizationId) + : await resolveSystemBillingAttribution(scope.workspaceId) await dispatchSync(connector.id, { billingAttribution, expectedNextSyncAt: connector.nextSyncAt ?? undefined, diff --git a/apps/sim/app/api/knowledge/search/route.test.ts b/apps/sim/app/api/knowledge/search/route.test.ts new file mode 100644 index 00000000000..e02b67e4203 --- /dev/null +++ b/apps/sim/app/api/knowledge/search/route.test.ts @@ -0,0 +1,64 @@ +/** + * @vitest-environment node + */ +import { authMockFns } from '@sim/testing' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ search: vi.fn() })) +vi.mock('@/lib/knowledge/application/workspace-search', () => ({ + searchScopedKnowledge: { operation: { id: 'knowledge.search' }, execute: mocks.search }, +})) + +import { POST } from '@/app/api/knowledge/search/route' + +describe('workspace search route', () => { + beforeEach(() => { + vi.clearAllMocks() + authMockFns.mockGetSession.mockResolvedValue({ + user: { id: 'user-1', email: 'reader@fixture.test', name: 'Reader' }, + session: { id: 'session-1' }, + }) + mocks.search.mockResolvedValue({ results: [], knowledgeBases: [] }) + }) + + it('passes the authenticated request cancellation signal through the existing operation', async () => { + const controller = new AbortController() + const request = new NextRequest('http://localhost/api/knowledge/search', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + workspaceId: 'workspace-1', + filters: { source: 'slack', documentIds: ['doc-1'] }, + query: 'Orion', + }), + signal: controller.signal, + }) + const response = await POST(request) + expect(response.status).toBe(200) + const call = mocks.search.mock.calls[0][0] + expect(call.principal).toEqual({ kind: 'session', userId: 'user-1', sessionId: 'session-1' }) + expect(call.input).not.toHaveProperty('knowledgeBaseIds') + expect(call.input.filters).toEqual({ source: 'slack', documentIds: ['doc-1'] }) + expect(call.input.signal).toBe(request.signal) + controller.abort() + expect(call.input.signal.aborted).toBe(true) + await expect(response.json()).resolves.toEqual({ + success: true, + data: { query: 'Orion', results: [] }, + }) + }) + + it('authenticates before parsing and never enters search for an anonymous request', async () => { + authMockFns.mockGetSession.mockResolvedValueOnce(null) + const response = await POST( + new NextRequest('http://localhost/api/knowledge/search', { + method: 'POST', + body: '{', + headers: { 'content-type': 'application/json' }, + }) + ) + expect(response.status).toBe(401) + expect(mocks.search).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/knowledge/search/route.ts b/apps/sim/app/api/knowledge/search/route.ts index 96d78f3d1d7..cc9ae79fb8b 100644 --- a/apps/sim/app/api/knowledge/search/route.ts +++ b/apps/sim/app/api/knowledge/search/route.ts @@ -6,7 +6,7 @@ import { } from '@/lib/api/server/routes' import { internalKnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' import { knowledgeOperations } from '@/lib/knowledge/application/operations' -import { searchKnowledge } from '@/lib/knowledge/application/search' +import { searchScopedKnowledge } from '@/lib/knowledge/application/workspace-search' import { sourceAuthor } from '@/lib/knowledge/search/author' export const POST = defineInternalJsonRoute({ @@ -14,16 +14,20 @@ export const POST = defineInternalJsonRoute({ auth: internalSessionAuth, operation: knowledgeOperations.search, rateLimit: internalRateLimits.none({ - reason: 'A person typing queries; the embedding call is metered against their workspace', + reason: + 'A person typing queries; the embedding call is metered against the canonical search owner', }), errorPolicy: internalKnowledgeErrorPolicies.search, - mapInput: ({ body }) => ({ + mapInput: ({ body }, { request }) => ({ workspaceId: body.workspaceId, - knowledgeBaseIds: body.knowledgeBaseIds, + organizationId: body.organizationId, + filters: body.filters, query: body.query, topK: body.topK, + surface: 'dashboard' as const, + signal: request.signal, }), - useCase: searchKnowledge, + useCase: searchScopedKnowledge, present: ({ results, knowledgeBases }, { input }) => { const knowledgeBaseNames = new Map(knowledgeBases.map((kb) => [kb.id, kb.name])) return { diff --git a/apps/sim/app/api/knowledge/search/utils.test.ts b/apps/sim/app/api/knowledge/search/utils.test.ts index fd09d744f64..e5184169e8f 100644 --- a/apps/sim/app/api/knowledge/search/utils.test.ts +++ b/apps/sim/app/api/knowledge/search/utils.test.ts @@ -18,6 +18,14 @@ import * as documentsUtilsModule from '@/lib/knowledge/documents/utils' import { runWithKnowledgeModelInputProvenance } from '@/lib/knowledge/model-input-provenance' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' +vi.mock('@/lib/core/rate-limiter/provider-admission', () => ({ + PROVIDER_QUOTA_COOLDOWN_MS: 300_000, + ProviderQuotaExhaustedError: class ProviderQuotaExhaustedError extends Error {}, + isProviderQuotaExhausted: vi.fn().mockResolvedValue(false), + recordProviderCooldown: vi.fn().mockResolvedValue(undefined), + waitForProviderAdmission: vi.fn().mockResolvedValue(undefined), +})) + /** * Spy on the real documents/utils namespace instead of vi.mock: the shared * `@/lib/knowledge/embeddings` module may be cached bound to the real module, @@ -196,6 +204,27 @@ describe('Knowledge Search Utils', () => { }) describe('handleTagAndVectorSearch', () => { + it('returns only bounded ranked rows without first materializing every matching tag ID', async () => { + resetDbChainMock() + queueTableRows(schemaMock.embedding, [makeResult('second', 0.2), makeResult('first', 0.1)]) + + const results = await handleTagAndVectorSearch({ + knowledgeBaseIds: ['kb-1', 'kb-2'], + access: WORKSPACE_ACCESS_SCOPE, + topK: 2, + structuredFilters: [ + { tagSlot: 'tag1', fieldType: 'text', operator: 'eq', value: 'common' }, + ], + queryVector: { vector: JSON.stringify(TEST_EMBEDDING), dimensions: 1536 }, + distanceThreshold: 0.8, + }) + + expect(results.map((row) => row.id)).toEqual(['first', 'second']) + expect(dbChainMockFns.select).toHaveBeenCalledTimes(1) + expect(dbChainMockFns.select.mock.calls[0][0]).toHaveProperty('distance') + expect(dbChainMockFns.limit).toHaveBeenCalledWith(2) + }) + it('should throw error when no filters provided', async () => { const params = { knowledgeBaseIds: ['kb-123'], diff --git a/apps/sim/app/api/knowledge/sim-search/connect/route.ts b/apps/sim/app/api/knowledge/sim-search/connect/route.ts index c13ebd49c16..9f2319ee6d3 100644 --- a/apps/sim/app/api/knowledge/sim-search/connect/route.ts +++ b/apps/sim/app/api/knowledge/sim-search/connect/route.ts @@ -14,11 +14,7 @@ export const POST = defineInternalJsonRoute({ operation: knowledgeOperations.simSearchConnect, rateLimit: internalRateLimits.none({ reason: 'One click per source; mints a single-use link' }), errorPolicy: internalKnowledgeErrorPolicies.connectors, - mapInput: ({ body }) => ({ - workspaceId: body.workspaceId, - connectorType: body.connectorType, - sourceConfig: body.sourceConfig, - }), + mapInput: ({ body }) => body, useCase: connectSimSearchConnector, present: (result) => ({ success: true as const, data: result }), }) diff --git a/apps/sim/app/api/knowledge/sim-search/index/route.ts b/apps/sim/app/api/knowledge/sim-search/index/route.ts new file mode 100644 index 00000000000..3b891520185 --- /dev/null +++ b/apps/sim/app/api/knowledge/sim-search/index/route.ts @@ -0,0 +1,20 @@ +import { readSearchIndexContract } from '@/lib/api/contracts/knowledge/connectors' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalKnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { readSearchIndex } from '@/lib/knowledge/application/sim-search' + +export const GET = defineInternalJsonRoute({ + contract: readSearchIndexContract, + auth: internalSessionAuth, + operation: knowledgeOperations.readSearchIndex, + rateLimit: internalRateLimits.user({ bucketName: 'knowledge.search.index' }), + errorPolicy: internalKnowledgeErrorPolicies.connectors, + mapInput: ({ query }) => query, + useCase: readSearchIndex, + present: (data) => ({ success: true, data: { knowledgeBaseId: data.knowledgeBaseId } }), +}) diff --git a/apps/sim/app/api/knowledge/sim-search/integrations/route.ts b/apps/sim/app/api/knowledge/sim-search/integrations/route.ts new file mode 100644 index 00000000000..439b5b6b082 --- /dev/null +++ b/apps/sim/app/api/knowledge/sim-search/integrations/route.ts @@ -0,0 +1,40 @@ +import { + listSearchIntegrationsContract, + updateSearchIntegrationContract, +} from '@/lib/api/contracts/knowledge/search-integrations' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalKnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { + approveSearchIntegration, + listSearchIntegrations, +} from '@/lib/knowledge/application/search-integrations' + +export const GET = defineInternalJsonRoute({ + contract: listSearchIntegrationsContract, + auth: internalSessionAuth, + operation: knowledgeOperations.listSearchIntegrations, + rateLimit: internalRateLimits.user({ bucketName: 'knowledge.search.integrations.list' }), + errorPolicy: internalKnowledgeErrorPolicies.connectors, + mapInput: ({ query }) => query, + useCase: listSearchIntegrations, + present: (data) => ({ success: true as const, data }), +}) + +export const PUT = defineInternalJsonRoute({ + contract: updateSearchIntegrationContract, + auth: internalSessionAuth, + operation: knowledgeOperations.approveSearchIntegration, + rateLimit: internalRateLimits.user({ bucketName: 'knowledge.search.integrations.approve' }), + errorPolicy: internalKnowledgeErrorPolicies.connectors, + mapInput: ({ body }) => body, + useCase: approveSearchIntegration, + present: ({ connectorType, approved }) => ({ + success: true as const, + data: { connectorType, approved }, + }), +}) diff --git a/apps/sim/app/api/knowledge/sim-search/prepare/route.ts b/apps/sim/app/api/knowledge/sim-search/prepare/route.ts new file mode 100644 index 00000000000..ffad32fe0c5 --- /dev/null +++ b/apps/sim/app/api/knowledge/sim-search/prepare/route.ts @@ -0,0 +1,20 @@ +import { prepareSearchSourceContract } from '@/lib/api/contracts/knowledge/connectors' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalKnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { prepareSearchSource } from '@/lib/knowledge/application/sim-search' + +export const POST = defineInternalJsonRoute({ + contract: prepareSearchSourceContract, + auth: internalSessionAuth, + operation: knowledgeOperations.prepareSearchSource, + rateLimit: internalRateLimits.user({ bucketName: 'knowledge.search.sources.prepare' }), + errorPolicy: internalKnowledgeErrorPolicies.connectors, + mapInput: ({ body }) => body, + useCase: prepareSearchSource, + present: (data) => ({ success: true as const, data }), +}) diff --git a/apps/sim/app/api/knowledge/sim-search/sources/route.test.ts b/apps/sim/app/api/knowledge/sim-search/sources/route.test.ts new file mode 100644 index 00000000000..a90e2cc09d0 --- /dev/null +++ b/apps/sim/app/api/knowledge/sim-search/sources/route.test.ts @@ -0,0 +1,155 @@ +/** @vitest-environment node */ +import { authMockFns, createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ execute: vi.fn(), connect: vi.fn() })) +vi.mock('@/lib/knowledge/application/sim-search', () => ({ + connectSimSearchConnector: { + operation: { id: 'knowledge.simSearch.connect' }, + execute: mocks.connect, + }, +})) +vi.mock('@/lib/knowledge/application/search-sources', () => ({ + listSearchSources: { operation: { id: 'knowledge.search.sources.list' }, execute: mocks.execute }, +})) +vi.mock('@/lib/knowledge/application/search', () => ({ + KnowledgeSearchProvenanceUnavailableError: class extends Error {}, +})) +vi.mock('@/lib/knowledge/application/upload-sessions', () => ({ + KnowledgeDocumentUnsupportedMediaTypeError: class extends Error {}, +})) + +import { NoWorkspaceAccessError } from '@/lib/core/application/workspace-authorization' +import { POST as connectSource } from '@/app/api/knowledge/sim-search/connect/route' +import { GET } from '@/app/api/knowledge/sim-search/sources/route' + +const WORKSPACE_ID = '7d28e5e2-fb03-4118-9c52-4ab77ccff369' +const source = { + knowledgeBaseId: 'search-index', + connectorId: 'source', + connectorType: 'google_drive', + sourceDescription: 'Handbook', + accessMode: 'admin', + availability: 'available', + enabled: true, + isSyncing: false, + lastSyncAt: null, + hasSyncError: false, + viewerDocumentCount: 0, + viewerEmailVerified: true, + connectionRequired: false, + viewerMembership: null, +} + +beforeEach(() => { + vi.clearAllMocks() + authMockFns.mockGetSession.mockResolvedValue({ + user: { id: 'reader' }, + session: { id: 'session' }, + }) + mocks.execute.mockResolvedValue({ sources: [source] }) +}) + +describe('GET Search sources', () => { + it('preserves the explicit organization in source listing and member enrollment', async () => { + const response = await GET( + createMockRequest( + 'GET', + undefined, + {}, + `http://localhost/api/knowledge/sim-search/sources?organizationId=${WORKSPACE_ID}` + ) + ) + expect(response.status).toBe(200) + expect(mocks.execute).toHaveBeenCalledWith( + expect.objectContaining({ + input: { organizationId: WORKSPACE_ID }, + }) + ) + + mocks.connect.mockResolvedValue({ + knowledgeBaseId: 'index', + connectorId: 'source', + url: 'http://localhost/credential-groups/enroll/token', + }) + const body = { organizationId: WORKSPACE_ID, connectorType: 'gmail' } + const connected = await connectSource(createMockRequest('POST', body)) + expect(connected.status).toBe(200) + expect(mocks.connect).toHaveBeenCalledWith(expect.objectContaining({ input: body })) + }) + + it('authenticates before parsing the workspace query', async () => { + authMockFns.mockGetSession.mockResolvedValue(null) + const response = await GET(createMockRequest('GET')) + expect(response.status).toBe(401) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('refuses a missing workspace before entering the use case', async () => { + const response = await GET(createMockRequest('GET')) + expect(response.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('passes the authenticated subject into the registered operation and projects only the contract fields', async () => { + mocks.execute.mockResolvedValue({ + sources: [ + { + ...source, + credentialId: 'secret', + sourceConfig: { token: 'secret' }, + lastSyncError: 'private failure', + }, + ], + }) + const response = await GET( + createMockRequest( + 'GET', + undefined, + {}, + `http://localhost/api/knowledge/sim-search/sources?workspaceId=${WORKSPACE_ID}` + ) + ) + expect(response.status).toBe(200) + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + const body = await response.json() + expect(body).toMatchObject({ success: true, data: [source] }) + expect(body.data[0]).toEqual(source) + expect(mocks.execute).toHaveBeenCalledWith( + expect.objectContaining({ + principal: { kind: 'session', userId: 'reader', sessionId: 'session' }, + input: { workspaceId: WORKSPACE_ID }, + }) + ) + }) + + it('preserves authorization rejection and conceals source data', async () => { + mocks.execute.mockRejectedValue(new NoWorkspaceAccessError()) + const response = await GET( + createMockRequest( + 'GET', + undefined, + {}, + `http://localhost/api/knowledge/sim-search/sources?workspaceId=${WORKSPACE_ID}` + ) + ) + expect(response.status).toBe(404) + expect(await response.json()).not.toHaveProperty('data') + }) + + it('does not publish infrastructure errors or mistake failures for an empty list', async () => { + mocks.execute.mockRejectedValue(new Error('database private connection string')) + const response = await GET( + createMockRequest( + 'GET', + undefined, + {}, + `http://localhost/api/knowledge/sim-search/sources?workspaceId=${WORKSPACE_ID}` + ) + ) + expect(response.status).toBe(500) + const body = await response.json() + expect(body.error).toBe('Internal server error') + expect(body).not.toHaveProperty('data') + }) +}) diff --git a/apps/sim/app/api/knowledge/sim-search/sources/route.ts b/apps/sim/app/api/knowledge/sim-search/sources/route.ts new file mode 100644 index 00000000000..fe64846acdd --- /dev/null +++ b/apps/sim/app/api/knowledge/sim-search/sources/route.ts @@ -0,0 +1,23 @@ +import { listSearchSourcesContract } from '@/lib/api/contracts/knowledge' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalKnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { listSearchSources } from '@/lib/knowledge/application/search-sources' + +export const GET = defineInternalJsonRoute({ + contract: listSearchSourcesContract, + auth: internalSessionAuth, + operation: knowledgeOperations.listSearchSources, + rateLimit: internalRateLimits.none({ + reason: 'Workspace source summaries for the Search page and indexing status polling', + }), + errorPolicy: internalKnowledgeErrorPolicies.connectors, + mapInput: ({ query }) => query, + useCase: listSearchSources, + present: ({ sources }) => ({ success: true as const, data: sources }), + staticResponseHeaders: { 'Cache-Control': 'private, no-store' }, +}) diff --git a/apps/sim/app/api/knowledge/utils.test.ts b/apps/sim/app/api/knowledge/utils.test.ts index 2fca7aa4ecc..47a3671dca8 100644 --- a/apps/sim/app/api/knowledge/utils.test.ts +++ b/apps/sim/app/api/knowledge/utils.test.ts @@ -19,6 +19,14 @@ import { env } from '@/lib/core/config/env' import * as documentsUtilsModule from '@/lib/knowledge/documents/utils' import * as workspacesUtilsModule from '@/lib/workspaces/utils' +vi.mock('@/lib/core/rate-limiter/provider-admission', () => ({ + PROVIDER_QUOTA_COOLDOWN_MS: 300_000, + ProviderQuotaExhaustedError: class ProviderQuotaExhaustedError extends Error {}, + isProviderQuotaExhausted: vi.fn().mockResolvedValue(false), + recordProviderCooldown: vi.fn().mockResolvedValue(undefined), + waitForProviderAdmission: vi.fn().mockResolvedValue(undefined), +})) + const envSnapshot = { ...env } afterAll(() => { diff --git a/apps/sim/app/api/mcp/oauth/callback/route.test.ts b/apps/sim/app/api/mcp/oauth/callback/route.test.ts index 8dfa1b88a23..d1af1e73161 100644 --- a/apps/sim/app/api/mcp/oauth/callback/route.test.ts +++ b/apps/sim/app/api/mcp/oauth/callback/route.test.ts @@ -10,6 +10,7 @@ import { } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { CredentialGroupOAuthStateVersionError } from '@/lib/credential-groups/oauth-attempt-version' const { mockAuthenticateEnrollment, @@ -30,7 +31,7 @@ vi.mock('@/lib/mcp/service', () => ({ mcpService: { discoverServerTools: mockDiscoverServerTools }, })) vi.mock('@/lib/credential-groups/application/enrollment-auth', () => ({ - authenticateCredentialGroupEnrollment: mockAuthenticateEnrollment, + credentialGroupOAuthAttemptPrincipal: mockAuthenticateEnrollment, })) vi.mock('@/lib/credential-groups/application/public-enrollment', () => ({ completePublicCredentialGroupMcpOAuth: { execute: mockCompleteManagedMcpOAuth }, @@ -68,6 +69,8 @@ describe('MCP OAuth callback route', () => { mockDiscoverServerTools.mockResolvedValue(undefined) mockConsumeManagedAttempt.mockResolvedValue({ state: 'mcp_cg_state-1', + workspaceId: 'workspace-1', + email: 'invitee@example.com', enrollmentId: 'enrollment-1', credentialGroupId: 'group-1', mcpServerId: 'server-1', @@ -75,7 +78,7 @@ describe('MCP OAuth callback route', () => { invitationToken: 'invitation-token', createdAt: Date.now(), }) - mockAuthenticateEnrollment.mockResolvedValue({ + mockAuthenticateEnrollment.mockReturnValue({ kind: 'credential_group_enrollment', workspaceId: 'workspace-1', credentialGroupId: 'group-1', @@ -159,7 +162,13 @@ describe('MCP OAuth callback route', () => { expect(mockEnforceCallbackRateLimit).toHaveBeenCalledWith(request, 'oauth-callback') expect(mockConsumeManagedAttempt).toHaveBeenCalledWith('mcp_cg_state-1') - expect(mockAuthenticateEnrollment).toHaveBeenCalledWith('invitation-token') + expect(mockAuthenticateEnrollment).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-1', + email: 'invitee@example.com', + invitationToken: 'invitation-token', + }) + ) expect(mockCompleteManagedMcpOAuth).toHaveBeenCalledWith( expect.objectContaining({ input: expect.objectContaining({ @@ -187,4 +196,13 @@ describe('MCP OAuth callback route', () => { expect(mockConsumeManagedAttempt).not.toHaveBeenCalled() expect(mockCompleteManagedMcpOAuth).not.toHaveBeenCalled() }) + it('reports a state protocol change without exchanging a code or loading an enrollment', async () => { + mockConsumeManagedAttempt.mockRejectedValue(new CredentialGroupOAuthStateVersionError()) + const response = await GET( + new NextRequest('http://localhost:3000/api/mcp/oauth/callback?state=mcp_cg_old&code=code') + ) + expect(await response.text()).toContain('Reopen your invitation and connect again') + expect(mockAuthenticateEnrollment).not.toHaveBeenCalled() + expect(mockCompleteManagedMcpOAuth).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/app/api/mcp/oauth/callback/route.ts b/apps/sim/app/api/mcp/oauth/callback/route.ts index f76addcbb80..dd31f4a24b2 100644 --- a/apps/sim/app/api/mcp/oauth/callback/route.ts +++ b/apps/sim/app/api/mcp/oauth/callback/route.ts @@ -9,12 +9,13 @@ import { mcpOauthCallbackContract } from '@/lib/api/contracts/mcp' import { parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { authenticateCredentialGroupEnrollment } from '@/lib/credential-groups/application/enrollment-auth' +import { credentialGroupOAuthAttemptPrincipal } from '@/lib/credential-groups/application/enrollment-auth' import { completePublicCredentialGroupMcpOAuth } from '@/lib/credential-groups/application/public-enrollment' import { consumeCredentialGroupMcpOAuthAttempt, isCredentialGroupMcpOAuthState, } from '@/lib/credential-groups/mcp-oauth-state' +import { CredentialGroupOAuthStateVersionError } from '@/lib/credential-groups/oauth-attempt-version' import { enforcePublicCredentialGroupIpRateLimit } from '@/lib/credential-groups/rate-limit' import { assertSafeOauthServerUrl, @@ -84,7 +85,15 @@ async function completeManagedMcpCallback(params: { code?: string error?: string }): Promise { - const attempt = await consumeCredentialGroupMcpOAuthAttempt(params.state) + let attempt + try { + attempt = await consumeCredentialGroupMcpOAuthAttempt(params.state) + } catch (error) { + if (error instanceof CredentialGroupOAuthStateVersionError) { + return htmlClose(error.message, false, 'invalid_state', undefined, params.state) + } + throw error + } if (!attempt) { return htmlClose('Invalid or expired authorization state.', false, 'invalid_state') } @@ -97,12 +106,7 @@ async function completeManagedMcpCallback(params: { }) } try { - const principal = await authenticateCredentialGroupEnrollment(attempt.invitationToken) - if (!principal) { - return createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { - oauth: 'unavailable', - }) - } + const principal = await credentialGroupOAuthAttemptPrincipal(attempt) const result = await completePublicCredentialGroupMcpOAuth.execute({ principal, input: { attempt, code: params.code }, @@ -199,7 +203,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { .where(and(eq(mcpServers.id, row.mcpServerId), isNull(mcpServers.deletedAt))) .limit(1) ) - if (!server || !server.url) { + if (!server || !server.url || !server.workspaceId) { return respond('Server no longer exists.', false, 'server_gone', serverId) } if (server.workspaceId !== row.workspaceId) { @@ -211,6 +215,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) } const serverUrl = server.url + const serverWorkspaceId = server.workspaceId try { assertSafeOauthServerUrl(serverUrl) } catch { @@ -262,7 +267,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => { try { // forceRefresh: skip any stale cache from before re-auth. await timedStep('discoverServerTools', 60_000, () => - mcpService.discoverServerTools(session.user.id, server.id, server.workspaceId, 'force') + mcpService.discoverServerTools(session.user.id, server.id, serverWorkspaceId, 'force') ) } catch (e) { logger.warn('Post-auth tools refresh failed', toError(e).message) diff --git a/apps/sim/app/api/mcp/search/[workspaceId]/route.ts b/apps/sim/app/api/mcp/search/[workspaceId]/route.ts new file mode 100644 index 00000000000..c6b7d737e3e --- /dev/null +++ b/apps/sim/app/api/mcp/search/[workspaceId]/route.ts @@ -0,0 +1,9 @@ +import { createKnowledgeMcpHandlers } from '@/lib/knowledge/mcp/route-handler' + +export const dynamic = 'force-dynamic' + +const handlers = createKnowledgeMcpHandlers('workspace') + +export const POST = handlers.POST +export const GET = handlers.GET +export const DELETE = handlers.DELETE diff --git a/apps/sim/app/api/mcp/search/organizations/[organizationId]/route.ts b/apps/sim/app/api/mcp/search/organizations/[organizationId]/route.ts new file mode 100644 index 00000000000..0f3758aaaf6 --- /dev/null +++ b/apps/sim/app/api/mcp/search/organizations/[organizationId]/route.ts @@ -0,0 +1,9 @@ +import { createKnowledgeMcpHandlers } from '@/lib/knowledge/mcp/route-handler' + +export const dynamic = 'force-dynamic' + +const handlers = createKnowledgeMcpHandlers('organization') + +export const POST = handlers.POST +export const GET = handlers.GET +export const DELETE = handlers.DELETE diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts index 2f17fb5ea34..be62590983a 100644 --- a/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts +++ b/apps/sim/app/api/mcp/serve/[serverId]/route.test.ts @@ -95,7 +95,7 @@ vi.mock('@/lib/auth/internal', () => ({ })) vi.mock('@/lib/core/execution-limits', () => ({ - getMaxExecutionTimeout: () => 10_000, + getMaxExecutionTimeout: () => 60_000, })) vi.mock('@/lib/workflows/executor/execute-service', () => ({ @@ -309,7 +309,10 @@ describe('MCP Serve Route', () => { const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { method: 'POST', - headers: { 'X-API-Key': 'pk_test_123' }, + headers: { + 'X-API-Key': 'pk_test_123', + Accept: 'application/json, text/event-stream;q=0', + }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, @@ -320,12 +323,14 @@ describe('MCP Serve Route', () => { const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) }) expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toContain('application/json') expect(mockExecuteWorkflowService).toHaveBeenCalledTimes(1) expect(mockExecuteWorkflowService).toHaveBeenCalledWith( expect.objectContaining({ workflowId: 'wf-1', userId: 'user-1', triggerType: 'mcp', + principal: PERSONAL_API_KEY_PRINCIPAL, useAuthenticatedUserAsActor: true, deploymentVersionId: 'deployment-1', includeFileBase64: false, @@ -338,6 +343,153 @@ describe('MCP Serve Route', () => { }) }) + it('keeps a Streamable HTTP tool call active and ends with its JSON-RPC response', async () => { + vi.useFakeTimers() + try { + dbChainMockFns.limit + .mockResolvedValueOnce([ + { + id: 'server-1', + name: 'Public Server', + workspaceId: 'ws-1', + isPublic: true, + createdBy: 'owner-1', + }, + ]) + .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) + .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) + + let finishExecution!: (result: unknown) => void + mockExecuteWorkflowService.mockReturnValueOnce( + new Promise((resolve) => { + finishExecution = resolve + }) + ) + + const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { + method: 'POST', + headers: { accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'tool_a', arguments: { q: 'test' } }, + }), + }) + const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) }) + + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toContain('text/event-stream') + if (!response.body) throw new Error('Expected MCP event stream') + const reader = response.body.getReader() + const decoder = new TextDecoder() + expect(decoder.decode((await reader.read()).value)).toBe(': keepalive\n\n') + await vi.advanceTimersByTimeAsync(15_000) + expect(decoder.decode((await reader.read()).value)).toBe(': keepalive\n\n') + + finishExecution({ + ok: true, + executionId: 'exec-1', + workflowId: 'wf-1', + status: 'completed', + aborted: null, + output: { ok: true }, + error: null, + hasResponseBlock: false, + resolvedSecretTraceProvenance: createResolvedSecretTraceProvenance('owner-1'), + }) + + const event = decoder.decode((await reader.read()).value) + expect(JSON.parse(event.replace(/^data: /, '').trim())).toMatchObject({ + jsonrpc: '2.0', + id: 1, + result: { content: [{ type: 'text' }], isError: false }, + }) + expect((await reader.read()).done).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it('serves metadata when standalone SSE GET is explicitly rejected', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([ + { + id: 'server-1', + name: 'Public Server', + workspaceId: 'ws-1', + isPublic: true, + createdBy: 'owner-1', + }, + ]) + + const request = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { + headers: { accept: 'application/json, text/event-stream;q=0' }, + }) + const response = await GET(request, { params: Promise.resolve({ serverId: 'server-1' }) }) + + expect(response.status).toBe(200) + expect(await response.json()).toMatchObject({ + name: 'Public Server', + capabilities: { tools: {} }, + }) + }) + + it('cancels the workflow when an MCP event-stream consumer disconnects', async () => { + dbChainMockFns.limit + .mockResolvedValueOnce([ + { + id: 'server-1', + name: 'Public Server', + workspaceId: 'ws-1', + isPublic: true, + createdBy: 'owner-1', + }, + ]) + .mockResolvedValueOnce([{ toolName: 'tool_a', workflowId: 'wf-1' }]) + .mockResolvedValueOnce([{ workspaceId: 'ws-1', deploymentVersionId: 'deployment-1' }]) + + let executionSignal: AbortSignal | undefined + mockExecuteWorkflowService.mockImplementationOnce( + ({ abortSignal }: { abortSignal: AbortSignal }) => + new Promise((resolve) => { + executionSignal = abortSignal + const finish = () => + resolve({ + ok: true, + executionId: 'exec-1', + workflowId: 'wf-1', + status: 'cancelled', + aborted: 'client', + output: undefined, + error: { message: 'Client cancelled request', code: 'CANCELLED' }, + hasResponseBlock: false, + }) + if (abortSignal.aborted) finish() + else abortSignal.addEventListener('abort', finish, { once: true }) + }) + ) + + const req = new NextRequest('http://localhost:3000/api/mcp/serve/server-1', { + method: 'POST', + headers: { accept: 'application/json, text/event-stream' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name: 'tool_a', arguments: { q: 'test' } }, + }), + }) + const response = await POST(req, { params: Promise.resolve({ serverId: 'server-1' }) }) + if (!response.body) throw new Error('Expected MCP event stream') + const reader = response.body.getReader() + await reader.read() + await vi.waitFor(() => expect(executionSignal).toBeDefined()) + + await reader.cancel('client disconnected') + + expect(executionSignal?.aborted).toBe(true) + }) + it('rejects a personal api key when the workspace disallows personal api keys', async () => { dbChainMockFns.limit.mockResolvedValueOnce([ { @@ -427,6 +579,7 @@ describe('MCP Serve Route', () => { expect(mockExecuteWorkflowService).toHaveBeenCalledWith( expect.objectContaining({ userId: 'user-1', + principal: WORKSPACE_API_KEY_PRINCIPAL, useAuthenticatedUserAsActor: false, }) ) diff --git a/apps/sim/app/api/mcp/serve/[serverId]/route.ts b/apps/sim/app/api/mcp/serve/[serverId]/route.ts index 6ec14605ad8..007de1cf4c5 100644 --- a/apps/sim/app/api/mcp/serve/[serverId]/route.ts +++ b/apps/sim/app/api/mcp/serve/[serverId]/route.ts @@ -27,6 +27,7 @@ import { workspace, } from '@sim/db/schema' import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' import { isRecordLike } from '@sim/utils/object' import { and, asc, eq, gt, isNull, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' @@ -44,7 +45,9 @@ import { resolveBillingAttribution, } from '@/lib/billing/core/billing-attribution' import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' +import { acceptsMediaType } from '@/lib/core/utils/media-types' import { generateRequestId } from '@/lib/core/utils/request' +import { encodeSSE, encodeSSEComment, SSE_HEADERS } from '@/lib/core/utils/sse' import { assertContentLengthWithinLimit, assertKnownSizeWithinLimit, @@ -75,6 +78,7 @@ const MAX_MCP_WORKFLOW_REQUEST_BYTES = 10 * 1024 * 1024 const MAX_MCP_TOOL_RESULT_TEXT_BYTES = 10 * 1024 * 1024 const MAX_MCP_TOOLS_LIST_COUNT = MAX_MCP_TOOLS_PER_SERVER const MAX_MCP_TOOLS_LIST_SCHEMA_BYTES = MAX_MCP_PARAMETER_SCHEMA_BYTES +const MCP_STREAM_KEEPALIVE_INTERVAL_MS = 15_000 const MB = 1024 * 1024 function negotiateProtocolVersion(rpcParams: unknown): string { @@ -137,6 +141,95 @@ function callerAbortedJsonRpcResponse( return abortSignal?.isCallerAborted() ? clientCancelledJsonRpcResponse(id) : null } +function acceptsEventStream(request: NextRequest): boolean { + return acceptsMediaType(request.headers.get('accept'), 'text/event-stream') +} + +/** + * Sends a Streamable HTTP response as SSE so a long tool call can keep the + * connection active before its terminal JSON-RPC message is available. + */ +function streamJsonRpcResponse( + id: RequestId, + requestSignal: AbortSignal, + run: (signal: AbortSignal) => Promise +): Response { + const executionController = new AbortController() + let cancelled = false + let keepaliveId: ReturnType | undefined + + const stopKeepalive = () => { + if (keepaliveId) { + clearInterval(keepaliveId) + keepaliveId = undefined + } + } + const abortExecution = (reason?: unknown) => { + if (!executionController.signal.aborted) { + executionController.abort(reason ?? new Error('MCP client disconnected')) + } + } + const abortFromRequest = () => abortExecution(requestSignal.reason) + + if (requestSignal.aborted) { + abortFromRequest() + } else { + requestSignal.addEventListener('abort', abortFromRequest, { once: true }) + } + + const stream = new ReadableStream({ + start(controller) { + const send = (chunk: Uint8Array): boolean => { + if (cancelled) return false + try { + controller.enqueue(chunk) + return true + } catch { + cancelled = true + stopKeepalive() + abortExecution() + return false + } + } + + if (send(encodeSSEComment('keepalive'))) { + keepaliveId = setInterval(() => { + send(encodeSSEComment('keepalive')) + }, MCP_STREAM_KEEPALIVE_INTERVAL_MS) + } + + void run(executionController.signal) + .then(async (response) => { + const message: unknown = await response.json() + send(encodeSSE(message)) + }) + .catch((error) => { + logger.error('MCP response stream failed', { error: getErrorMessage(error) }) + send(encodeSSE(createError(id, ErrorCode.InternalError, 'Internal error'))) + }) + .finally(() => { + stopKeepalive() + requestSignal.removeEventListener('abort', abortFromRequest) + if (!cancelled) controller.close() + }) + }, + cancel(reason) { + cancelled = true + stopKeepalive() + requestSignal.removeEventListener('abort', abortFromRequest) + abortExecution(reason) + }, + }) + + return new Response(stream, { + headers: { + ...SSE_HEADERS, + 'Cache-Control': 'no-cache, no-transform', + Vary: 'Accept', + }, + }) +} + function limitMessage(label: string, maxBytes: number): string { return `${label} exceeds maximum size of ${Math.round(maxBytes / MB)}MB` } @@ -406,12 +499,12 @@ async function authorizeMcpServeRequest( } } -function unsupportedSseTransportResponse(): NextResponse { +function unsupportedSseGetResponse(): NextResponse { return NextResponse.json( { error: { code: 'unsupported_transport', - message: 'SSE transport is not supported for workflow MCP servers', + message: 'Standalone SSE GET transport is not supported for workflow MCP servers', supportedTransports: ['streamable-http'], allowedMethods: ['GET', 'POST', 'DELETE'], }, @@ -438,8 +531,8 @@ export const GET = withRouteHandler( const authResult = await authorizeMcpServeRequest(request, server) if (authResult.response) return authResult.response - if (request.headers.get('accept')?.includes('text/event-stream')) { - return unsupportedSseTransportResponse() + if (acceptsEventStream(request)) { + return unsupportedSseGetResponse() } return NextResponse.json({ @@ -557,16 +650,22 @@ export const POST = withRouteHandler( ) } - return handleToolsCall( - id, - serverId, - server.workspaceId, - paramsValidation.data, - executeAuthContext, - server.isPublic ? server.createdBy : undefined, - request.headers.get(SIM_VIA_HEADER), - request.signal - ) + const callTool = (signal: AbortSignal) => + handleToolsCall( + id, + serverId, + server.workspaceId, + paramsValidation.data, + executeAuthContext, + server.isPublic ? server.createdBy : undefined, + request.headers.get(SIM_VIA_HEADER), + signal + ) + + if (acceptsEventStream(request)) { + return streamJsonRpcResponse(id, request.signal, callTool) + } + return callTool(request.signal) } default: diff --git a/apps/sim/app/api/mothership/chat/route.ts b/apps/sim/app/api/mothership/chat/route.ts index 6351971fd8c..deff41844d0 100644 --- a/apps/sim/app/api/mothership/chat/route.ts +++ b/apps/sim/app/api/mothership/chat/route.ts @@ -5,11 +5,11 @@ import { } from '@/lib/api/contracts/mothership-chats' import { validationErrorResponse } from '@/lib/api/server' import { getSession } from '@/lib/auth' -import { handleUnifiedChatPost, maxDuration } from '@/lib/copilot/chat/post' +import { handleUnifiedChatPost } from '@/lib/copilot/chat/post' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { GET as copilotChatGet } from '@/app/api/copilot/chat/queries' -export { maxDuration } +export const maxDuration = 3600 // Unified chat route surface. export const GET = withRouteHandler((request: NextRequest) => { diff --git a/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts b/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts index 4f407ffea71..740456f750b 100644 --- a/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts +++ b/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts @@ -14,6 +14,7 @@ import { } from '@/lib/copilot/chat/fork-chat-files' import { loadCopilotChatMessages } from '@/lib/copilot/chat/lifecycle' import { appendCopilotChatMessages } from '@/lib/copilot/chat/messages-store' +import { authorizeOrganizationChat } from '@/lib/copilot/chat/organization-chats' import { rewriteMessageFileRefs, rewriteResourceFileRefs, @@ -32,6 +33,7 @@ import { removeChatResources } from '@/lib/copilot/resources/persistence' import { type MothershipResource, sanitizeChatResources } from '@/lib/copilot/resources/types' import { getMothershipBaseURL, getMothershipSourceEnvHeaders } from '@/lib/copilot/server/agent-url' import { env } from '@/lib/core/config/env' +import { asOrchestrationError } from '@/lib/core/orchestration/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { @@ -58,7 +60,7 @@ const logger = createLogger('ForkChatAPI') export const POST = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ chatId: string }> }) => { try { - const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() + const { userId, isAuthenticated, principal } = await authenticateCopilotRequestSessionOnly() if (!isAuthenticated || !userId) { return createUnauthorizedResponse() } @@ -76,6 +78,7 @@ export const POST = withRouteHandler( userId: copilotChats.userId, type: copilotChats.type, workspaceId: copilotChats.workspaceId, + organizationId: copilotChats.organizationId, title: copilotChats.title, model: copilotChats.model, resources: copilotChats.resources, @@ -90,6 +93,13 @@ export const POST = withRouteHandler( return createNotFoundResponse('Chat not found') } + if (parent.organizationId) { + if (!principal) return createUnauthorizedResponse() + await authorizeOrganizationChat.execute({ + principal, + input: { organizationId: parent.organizationId }, + }) + } if (parent.workspaceId) { await assertActiveWorkspaceAccess(parent.workspaceId, userId) } @@ -137,6 +147,7 @@ export const POST = withRouteHandler( id: newId, userId, workspaceId: parent.workspaceId, + organizationId: parent.organizationId, type: parent.type, title, model: parent.model, @@ -277,6 +288,9 @@ export const POST = withRouteHandler( ...(failed > 0 ? { failedFileCopies: failed } : {}), }) } catch (error) { + const code = asOrchestrationError(error)?.code + if (code === 'not_found' || code === 'forbidden') + return NextResponse.json({ error: 'Chat not found' }, { status: 404 }) if (isWorkspaceAccessDeniedError(error)) { return createForbiddenResponse('Workspace access denied') } diff --git a/apps/sim/app/api/mothership/chats/[chatId]/restore/route.ts b/apps/sim/app/api/mothership/chats/[chatId]/restore/route.ts index b59bcceac05..b98f6824e1f 100644 --- a/apps/sim/app/api/mothership/chats/[chatId]/restore/route.ts +++ b/apps/sim/app/api/mothership/chats/[chatId]/restore/route.ts @@ -5,6 +5,7 @@ import { and, eq, isNotNull } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { restoreMothershipChatContract } from '@/lib/api/contracts/mothership-chats' import { parseRequest } from '@/lib/api/server' +import { authorizeOrganizationChat } from '@/lib/copilot/chat/organization-chats' import { chatPubSub } from '@/lib/copilot/chat-status' import { authenticateCopilotRequestSessionOnly, @@ -12,6 +13,7 @@ import { createInternalServerErrorResponse, createUnauthorizedResponse, } from '@/lib/copilot/request/http' +import { asOrchestrationError } from '@/lib/core/orchestration/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { @@ -31,7 +33,7 @@ const logger = createLogger('RestoreMothershipChatAPI') export const POST = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ chatId: string }> }) => { try { - const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() + const { userId, isAuthenticated, principal } = await authenticateCopilotRequestSessionOnly() if (!isAuthenticated || !userId) { return createUnauthorizedResponse() } @@ -41,7 +43,10 @@ export const POST = withRouteHandler( const { chatId } = parsed.data.params const [chat] = await db - .select({ workspaceId: copilotChats.workspaceId }) + .select({ + workspaceId: copilotChats.workspaceId, + organizationId: copilotChats.organizationId, + }) .from(copilotChats) .where( and( @@ -56,6 +61,13 @@ export const POST = withRouteHandler( if (!chat) { return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 }) } + if (chat.organizationId) { + if (!principal) return createUnauthorizedResponse() + await authorizeOrganizationChat.execute({ + principal, + input: { organizationId: chat.organizationId }, + }) + } if (chat.workspaceId) { await assertActiveWorkspaceAccess(chat.workspaceId, userId) } @@ -76,6 +88,7 @@ export const POST = withRouteHandler( ) .returning({ workspaceId: copilotChats.workspaceId, + organizationId: copilotChats.organizationId, }) if (!restoredChat) { @@ -100,6 +113,9 @@ export const POST = withRouteHandler( return NextResponse.json({ success: true }) } catch (error) { + const code = asOrchestrationError(error)?.code + if (code === 'not_found' || code === 'forbidden') + return NextResponse.json({ error: 'Chat not found' }, { status: 404 }) if (isWorkspaceAccessDeniedError(error)) { return createForbiddenResponse('Workspace access denied') } diff --git a/apps/sim/app/api/mothership/chats/[chatId]/route.ts b/apps/sim/app/api/mothership/chats/[chatId]/route.ts index 9fa07c1e067..6d321e169f3 100644 --- a/apps/sim/app/api/mothership/chats/[chatId]/route.ts +++ b/apps/sim/app/api/mothership/chats/[chatId]/route.ts @@ -36,7 +36,7 @@ const logger = createLogger('MothershipChatAPI') export const GET = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ chatId: string }> }) => { try { - const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() + const { userId, isAuthenticated, principal } = await authenticateCopilotRequestSessionOnly() if (!isAuthenticated || !userId) { return createUnauthorizedResponse() } @@ -45,7 +45,7 @@ export const GET = withRouteHandler( if (!paramsResult.success) return paramsResult.response const { chatId } = paramsResult.data.params - const chat = await getAccessibleCopilotChatWithMessages(chatId, userId) + const chat = await getAccessibleCopilotChatWithMessages(chatId, userId, { principal }) if (!chat || chat.type !== 'mothership') { return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 }) } @@ -154,7 +154,7 @@ export const GET = withRouteHandler( export const PATCH = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ chatId: string }> }) => { try { - const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() + const { userId, isAuthenticated, principal } = await authenticateCopilotRequestSessionOnly() if (!isAuthenticated || !userId) { return createUnauthorizedResponse() } @@ -163,6 +163,10 @@ export const PATCH = withRouteHandler( if (!parsed.success) return parsed.response const { chatId } = parsed.data.params const { title, isUnread, pinned } = parsed.data.body + const chat = await getAccessibleCopilotChatAuth(chatId, userId, { principal }) + if (!chat || chat.type !== 'mothership') { + return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 }) + } const updates: Record = {} @@ -250,7 +254,7 @@ export const PATCH = withRouteHandler( export const DELETE = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ chatId: string }> }) => { try { - const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() + const { userId, isAuthenticated, principal } = await authenticateCopilotRequestSessionOnly() if (!isAuthenticated || !userId) { return createUnauthorizedResponse() } @@ -259,7 +263,7 @@ export const DELETE = withRouteHandler( if (!parsed.success) return parsed.response const { chatId } = parsed.data.params - const chat = await getAccessibleCopilotChatAuth(chatId, userId) + const chat = await getAccessibleCopilotChatAuth(chatId, userId, { principal }) if (!chat || chat.type !== 'mothership') { return NextResponse.json({ success: true }) } diff --git a/apps/sim/app/api/mothership/chats/read/route.test.ts b/apps/sim/app/api/mothership/chats/read/route.test.ts index 1ff8c0a60fd..5a67be8f8a6 100644 --- a/apps/sim/app/api/mothership/chats/read/route.test.ts +++ b/apps/sim/app/api/mothership/chats/read/route.test.ts @@ -5,13 +5,17 @@ import { copilotHttpMock, copilotHttpMockFns, dbChainMockFns, resetDbChainMock } import { NextRequest } from 'next/server' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockParseRequest } = vi.hoisted(() => ({ +const { mockParseRequest, mockGetAccessibleChat } = vi.hoisted(() => ({ mockParseRequest: vi.fn(), + mockGetAccessibleChat: vi.fn(), })) vi.mock('@/lib/copilot/request/http', () => copilotHttpMock) vi.mock('@/lib/api/server', () => ({ parseRequest: mockParseRequest })) vi.mock('@/lib/api/contracts/mothership-chats', () => ({ markMothershipChatReadContract: {} })) +vi.mock('@/lib/copilot/chat/lifecycle', () => ({ + getAccessibleCopilotChatAuth: mockGetAccessibleChat, +})) import { POST } from '@/app/api/mothership/chats/read/route' @@ -29,7 +33,9 @@ describe('POST /api/mothership/chats/read', () => { copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValue({ userId: 'user-1', isAuthenticated: true, + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, }) + mockGetAccessibleChat.mockResolvedValue({ id: 'chat-1', userId: 'user-1' }) mockParseRequest.mockResolvedValue({ success: true, data: { body: { chatId: 'chat-1' } } }) }) @@ -40,6 +46,9 @@ describe('POST /api/mothership/chats/read', () => { it('guards the lastSeenAt write with the unread predicate (only writes when unread)', async () => { const res = await POST(createRequest()) expect(res.status).toBe(200) + expect(mockGetAccessibleChat).toHaveBeenCalledWith('chat-1', 'user-1', { + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + }) expect(dbChainMockFns.update).toHaveBeenCalledTimes(1) const whereArg = dbChainMockFns.where.mock.calls[0][0] as { @@ -58,6 +67,13 @@ describe('POST /api/mothership/chats/read', () => { ) }) + it('does not update a chat the caller can no longer access', async () => { + mockGetAccessibleChat.mockResolvedValueOnce(null) + const res = await POST(createRequest()) + expect(res.status).toBe(200) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + it('does not touch the database when unauthenticated', async () => { copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValue({ userId: null, diff --git a/apps/sim/app/api/mothership/chats/read/route.ts b/apps/sim/app/api/mothership/chats/read/route.ts index beffb8c821d..1c2cc149f72 100644 --- a/apps/sim/app/api/mothership/chats/read/route.ts +++ b/apps/sim/app/api/mothership/chats/read/route.ts @@ -5,6 +5,7 @@ import { and, eq, isNull, lt, or, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { markMothershipChatReadContract } from '@/lib/api/contracts/mothership-chats' import { parseRequest } from '@/lib/api/server' +import { getAccessibleCopilotChatAuth } from '@/lib/copilot/chat/lifecycle' import { authenticateCopilotRequestSessionOnly, createInternalServerErrorResponse, @@ -16,7 +17,7 @@ const logger = createLogger('MarkTaskReadAPI') export const POST = withRouteHandler(async (request: NextRequest) => { try { - const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() + const { userId, isAuthenticated, principal } = await authenticateCopilotRequestSessionOnly() if (!isAuthenticated || !userId) { return createUnauthorizedResponse() } @@ -24,6 +25,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { const parsed = await parseRequest(markMothershipChatReadContract, request, {}) if (!parsed.success) return parsed.response const { chatId } = parsed.data.body + const chat = await getAccessibleCopilotChatAuth(chatId, userId, { principal }) + if (!chat) return NextResponse.json({ success: true }) await db .update(copilotChats) diff --git a/apps/sim/app/api/mothership/chats/route.ts b/apps/sim/app/api/mothership/chats/route.ts index acdbfddb2a9..bc1828f2579 100644 --- a/apps/sim/app/api/mothership/chats/route.ts +++ b/apps/sim/app/api/mothership/chats/route.ts @@ -8,6 +8,10 @@ import { } from '@/lib/api/contracts/mothership-chats' import { parseRequest } from '@/lib/api/server' import { listMothershipChats } from '@/lib/copilot/chat/list-mothership-chats' +import { + createOrganizationChat, + listOrganizationChats, +} from '@/lib/copilot/chat/organization-chats' import { chatPubSub } from '@/lib/copilot/chat-status' import { MOTHERSHIP_CHAT_DEFAULT_MODEL } from '@/lib/copilot/constants' import { @@ -16,6 +20,7 @@ import { createInternalServerErrorResponse, createUnauthorizedResponse, } from '@/lib/copilot/request/http' +import { asOrchestrationError } from '@/lib/core/orchestration/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import { @@ -31,21 +36,34 @@ const logger = createLogger('MothershipChatsAPI') */ export const GET = withRouteHandler(async (request: NextRequest) => { try { - const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() + const { userId, isAuthenticated, principal } = await authenticateCopilotRequestSessionOnly() if (!isAuthenticated || !userId) { return createUnauthorizedResponse() } const queryResult = await parseRequest(listMothershipChatsContract, request, {}) if (!queryResult.success) return queryResult.response - const { workspaceId, scope } = queryResult.data.query + const { workspaceId, organizationId, scope } = queryResult.data.query + + if (organizationId) { + if (!principal) return createUnauthorizedResponse() + const data = await listOrganizationChats.execute({ + principal, + input: { organizationId, scope }, + }) + return NextResponse.json({ success: true, data }) + } + if (!workspaceId) throw new Error('Conversation owner is required') await assertActiveWorkspaceAccess(workspaceId, userId) const data = await listMothershipChats(userId, workspaceId, scope) return NextResponse.json({ success: true, data }) } catch (error) { + const code = asOrchestrationError(error)?.code + if (code === 'not_found' || code === 'forbidden') + return createForbiddenResponse('Organization access denied') if (isWorkspaceAccessDeniedError(error)) { return createForbiddenResponse('Workspace access denied') } @@ -60,15 +78,22 @@ export const GET = withRouteHandler(async (request: NextRequest) => { */ export const POST = withRouteHandler(async (request: NextRequest) => { try { - const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() + const { userId, isAuthenticated, principal } = await authenticateCopilotRequestSessionOnly() if (!isAuthenticated || !userId) { return createUnauthorizedResponse() } const validation = await parseRequest(createMothershipChatContract, request, {}) if (!validation.success) return validation.response - const { workspaceId } = validation.data.body + const { workspaceId, organizationId } = validation.data.body + + if (organizationId) { + if (!principal) return createUnauthorizedResponse() + const chat = await createOrganizationChat.execute({ principal, input: { organizationId } }) + return NextResponse.json({ success: true, id: chat.id }) + } + if (!workspaceId) throw new Error('Conversation owner is required') await assertActiveWorkspaceAccess(workspaceId, userId) const now = new Date() @@ -98,6 +123,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ success: true, id: chat.id }) } catch (error) { + const code = asOrchestrationError(error)?.code + if (code === 'not_found' || code === 'forbidden') + return createForbiddenResponse('Organization access denied') if (isWorkspaceAccessDeniedError(error)) { return createForbiddenResponse('Workspace access denied') } diff --git a/apps/sim/app/api/mothership/execute/route.ts b/apps/sim/app/api/mothership/execute/route.ts index e01f727f591..2e1cd9fd38c 100644 --- a/apps/sim/app/api/mothership/execute/route.ts +++ b/apps/sim/app/api/mothership/execute/route.ts @@ -24,6 +24,7 @@ import { requestExplicitStreamAbort } from '@/lib/copilot/request/session/explic import type { StreamEvent } from '@/lib/copilot/request/types' import { normalizeSecretMountPolicy } from '@/lib/copilot/secret-mount-policy' import { isDocSandboxEnabled } from '@/lib/core/config/env-flags' +import { acceptsMediaType } from '@/lib/core/utils/media-types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils' import { @@ -82,7 +83,7 @@ function isAbortError(error: unknown): boolean { function wantsStreamedExecuteResponse(req: NextRequest): boolean { return ( req.headers.get(MOTHERSHIP_EXECUTE_STREAM_HEADER) === MOTHERSHIP_EXECUTE_STREAM_VALUE || - req.headers.get('accept')?.includes(MOTHERSHIP_EXECUTE_STREAM_CONTENT_TYPE) === true + acceptsMediaType(req.headers.get('accept'), MOTHERSHIP_EXECUTE_STREAM_CONTENT_TYPE) ) } @@ -236,7 +237,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { workspaceAccess, secretMountPolicy, }), - buildIntegrationToolSchemas(userId, messageId, undefined, workspaceId), + buildIntegrationToolSchemas(userId, undefined, workspaceId), mothershipToolsPromise, computeWorkspaceEntitlements(workspaceId, userId), processContextsServer( diff --git a/apps/sim/app/api/organization-credentials/[id]/route.ts b/apps/sim/app/api/organization-credentials/[id]/route.ts new file mode 100644 index 00000000000..c1b7f569d07 --- /dev/null +++ b/apps/sim/app/api/organization-credentials/[id]/route.ts @@ -0,0 +1,23 @@ +import { updateOrganizationCredentialContract } from '@/lib/api/contracts/organization-credentials' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalCredentialErrorPolicy } from '@/lib/credentials/api/route-policies' +import { + organizationCredentialOperations, + updateOrganizationCredential, +} from '@/lib/credentials/application/organization-credentials' +import { toOrganizationCredential } from '@/lib/credentials/application/presentation' + +export const PATCH = defineInternalJsonRoute({ + contract: updateOrganizationCredentialContract, + auth: internalSessionAuth, + operation: organizationCredentialOperations.update, + rateLimit: internalRateLimits.none({ reason: 'Preserve credential update behavior' }), + errorPolicy: internalCredentialErrorPolicy, + mapInput: ({ body, params }) => ({ ...body, credentialId: params.id }), + useCase: updateOrganizationCredential, + present: ({ credential }) => ({ credential: toOrganizationCredential(credential) }), +}) diff --git a/apps/sim/app/api/organization-credentials/draft/route.ts b/apps/sim/app/api/organization-credentials/draft/route.ts new file mode 100644 index 00000000000..467335d73a6 --- /dev/null +++ b/apps/sim/app/api/organization-credentials/draft/route.ts @@ -0,0 +1,22 @@ +import { createOrganizationCredentialDraftContract } from '@/lib/api/contracts/organization-credentials' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalCredentialErrorPolicy } from '@/lib/credentials/api/route-policies' +import { + organizationCredentialOperations, + saveOrganizationCredentialDraft, +} from '@/lib/credentials/application/organization-credentials' + +export const POST = defineInternalJsonRoute({ + contract: createOrganizationCredentialDraftContract, + auth: internalSessionAuth, + operation: organizationCredentialOperations.saveDraft, + rateLimit: internalRateLimits.none({ reason: 'Preserve OAuth draft behavior' }), + errorPolicy: internalCredentialErrorPolicy, + mapInput: ({ body }) => body, + useCase: saveOrganizationCredentialDraft, + present: (result) => result, +}) diff --git a/apps/sim/app/api/organization-credentials/oauth/route.ts b/apps/sim/app/api/organization-credentials/oauth/route.ts new file mode 100644 index 00000000000..220f8767f4c --- /dev/null +++ b/apps/sim/app/api/organization-credentials/oauth/route.ts @@ -0,0 +1,30 @@ +import { listOrganizationOAuthCredentialsContract } from '@/lib/api/contracts/organization-credentials' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalCredentialErrorPolicy } from '@/lib/credentials/api/route-policies' +import { + listOrganizationCredentials, + organizationCredentialOperations, +} from '@/lib/credentials/application/organization-credentials' +import type { OAuthProvider } from '@/lib/oauth/types' + +export const GET = defineInternalJsonRoute({ + contract: listOrganizationOAuthCredentialsContract, + auth: internalSessionAuth, + operation: organizationCredentialOperations.list, + rateLimit: internalRateLimits.none({ reason: 'Preserve OAuth credential listing behavior' }), + errorPolicy: internalCredentialErrorPolicy, + mapInput: ({ query }) => ({ ...query, type: 'oauth' as const }), + useCase: listOrganizationCredentials, + present: ({ credentials }) => ({ + credentials: credentials.map((row) => ({ + id: row.id, + name: row.displayName, + provider: row.providerId as OAuthProvider, + type: 'oauth' as const, + })), + }), +}) diff --git a/apps/sim/app/api/organization-credentials/route.ts b/apps/sim/app/api/organization-credentials/route.ts new file mode 100644 index 00000000000..58cab694ecc --- /dev/null +++ b/apps/sim/app/api/organization-credentials/route.ts @@ -0,0 +1,38 @@ +import { + createOrganizationCredentialContract, + listOrganizationCredentialsContract, +} from '@/lib/api/contracts/organization-credentials' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalCredentialErrorPolicy } from '@/lib/credentials/api/route-policies' +import { + createOrganizationCredential, + listOrganizationCredentials, + organizationCredentialOperations, +} from '@/lib/credentials/application/organization-credentials' +import { toOrganizationCredential } from '@/lib/credentials/application/presentation' + +export const GET = defineInternalJsonRoute({ + contract: listOrganizationCredentialsContract, + auth: internalSessionAuth, + operation: organizationCredentialOperations.list, + rateLimit: internalRateLimits.none({ reason: 'Preserve credential listing behavior' }), + errorPolicy: internalCredentialErrorPolicy, + mapInput: ({ query }) => query, + useCase: listOrganizationCredentials, + present: ({ credentials }) => ({ credentials: credentials.map(toOrganizationCredential) }), +}) +export const POST = defineInternalJsonRoute({ + contract: createOrganizationCredentialContract, + auth: internalSessionAuth, + operation: organizationCredentialOperations.create, + rateLimit: internalRateLimits.none({ reason: 'Preserve credential creation behavior' }), + errorPolicy: internalCredentialErrorPolicy, + mapInput: ({ body }) => body, + useCase: createOrganizationCredential, + present: ({ credential }) => ({ credential: toOrganizationCredential(credential) }), + statusForResult: ({ created }) => (created ? 201 : 200), +}) diff --git a/apps/sim/app/api/organizations/[id]/connected-accounts/[groupId]/route.ts b/apps/sim/app/api/organizations/[id]/connected-accounts/[groupId]/route.ts new file mode 100644 index 00000000000..acda6a7d872 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/connected-accounts/[groupId]/route.ts @@ -0,0 +1,25 @@ +import { updateOrganizationAccountsContract } from '@/lib/api/contracts/organization-accounts' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + organizationAccountOperations, + updateOrganizationAccountsSettings, +} from '@/lib/credential-groups/application/organization-accounts' +import { createCredentialGroupInternalErrorPolicy } from '@/app/api/workspaces/[id]/credential-groups/error-policy' + +export const PATCH = defineInternalJsonRoute({ + contract: updateOrganizationAccountsContract, + auth: internalSessionAuth, + operation: organizationAccountOperations.update, + rateLimit: internalRateLimits.none({ reason: 'Administrator account configuration mutation' }), + errorPolicy: createCredentialGroupInternalErrorPolicy('Failed to update connected accounts'), + mapInput: ({ params, body }) => ({ + organizationId: params.id, + credentialGroupId: params.groupId, + update: body, + }), + useCase: updateOrganizationAccountsSettings, +}) diff --git a/apps/sim/app/api/organizations/[id]/connected-accounts/[groupId]/slack-managed-users/route.test.ts b/apps/sim/app/api/organizations/[id]/connected-accounts/[groupId]/slack-managed-users/route.test.ts new file mode 100644 index 00000000000..146a97011f1 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/connected-accounts/[groupId]/slack-managed-users/route.test.ts @@ -0,0 +1,77 @@ +/** @vitest-environment node */ +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ session: vi.fn(), execute: vi.fn() })) +vi.mock('@/lib/auth', () => ({ getSession: mocks.session })) +vi.mock('@/lib/credential-groups/application/slack-managed-users', () => ({ + startSlackCredentialGroupConfiguration: { + get operation() { + return credentialGroupOperations.startSlackConfiguration + }, + execute: mocks.execute, + }, +})) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { credentialGroupOperations } from '@/lib/credential-groups/application/operations' +import { POST } from '@/app/api/organizations/[id]/connected-accounts/[groupId]/slack-managed-users/route' + +const body = { + appId: 'A123', + teamId: 'T123', + clientId: 'fixture-client-id', + clientSecret: 'fixture-client-secret', +} +const context = { params: Promise.resolve({ id: 'org-a', groupId: 'group-a' }) } +function request(input: unknown = body) { + return createMockRequest( + 'POST', + input, + undefined, + 'http://localhost:3000/api/organizations/org-a/connected-accounts/group-a/slack-managed-users' + ) +} + +beforeEach(() => { + vi.clearAllMocks() + mocks.session.mockResolvedValue({ user: { id: 'actor' }, session: { id: 'session' } }) + mocks.execute.mockResolvedValue({ + authorizationUrl: 'https://slack.com/oauth/v2/authorize', + state: 'opaque-state', + }) +}) + +describe('organization Slack setup route', () => { + it('authenticates before parsing setup secrets', async () => { + mocks.session.mockResolvedValue(null) + const response = await POST(request({}), context) + expect(response.status).toBe(401) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('maps the canonical route id to organization ownership without a workspace alias', async () => { + const response = await POST(request(), context) + expect(response.status).toBe(200) + expect(mocks.execute).toHaveBeenCalledWith( + expect.objectContaining({ + principal: { kind: 'session', sessionId: 'session', userId: 'actor' }, + input: { ...body, organizationId: 'org-a', credentialGroupId: 'group-a' }, + }) + ) + }) + + it('rejects a client-supplied workspace owner', async () => { + const response = await POST(request({ ...body, workspaceId: 'workspace-a' }), context) + expect(response.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('preserves refusal when current organization authority is insufficient', async () => { + mocks.execute.mockRejectedValue( + new OrchestrationError('forbidden', 'Organization admin required') + ) + const response = await POST(request(), context) + expect(response.status).toBe(403) + }) +}) diff --git a/apps/sim/app/api/organizations/[id]/connected-accounts/[groupId]/slack-managed-users/route.ts b/apps/sim/app/api/organizations/[id]/connected-accounts/[groupId]/slack-managed-users/route.ts new file mode 100644 index 00000000000..32f1f8dcba4 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/connected-accounts/[groupId]/slack-managed-users/route.ts @@ -0,0 +1,34 @@ +import { startOrganizationSlackConfigurationContract } from '@/lib/api/contracts/organization-accounts' +import { + defineInternalJsonRoute, + extendInternalErrorPolicy, + internalErrorResponse, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { credentialGroupOperations } from '@/lib/credential-groups/application/operations' +import { startSlackCredentialGroupConfiguration } from '@/lib/credential-groups/application/slack-managed-users' +import { SlackManagedUsersError } from '@/lib/credential-groups/slack-managed-users' +import { createCredentialGroupInternalErrorPolicy } from '@/app/api/workspaces/[id]/credential-groups/error-policy' + +export const POST = defineInternalJsonRoute({ + contract: startOrganizationSlackConfigurationContract, + auth: internalSessionAuth, + operation: credentialGroupOperations.startSlackConfiguration, + rateLimit: internalRateLimits.none({ + reason: 'Slack applies provider authorization limits and setup requires an organization admin', + }), + errorPolicy: extendInternalErrorPolicy( + createCredentialGroupInternalErrorPolicy('Failed to configure Slack'), + (error) => + error instanceof SlackManagedUsersError + ? internalErrorResponse(400, { error: error.message }) + : null + ), + mapInput: ({ params, body }) => ({ + ...body, + organizationId: params.id, + credentialGroupId: params.groupId, + }), + useCase: startSlackCredentialGroupConfiguration, +}) diff --git a/apps/sim/app/api/organizations/[id]/connected-accounts/connect/route.ts b/apps/sim/app/api/organizations/[id]/connected-accounts/connect/route.ts new file mode 100644 index 00000000000..1b20890214e --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/connected-accounts/connect/route.ts @@ -0,0 +1,26 @@ +import { startOrganizationAccountConnectionContract } from '@/lib/api/contracts/organization-accounts' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + organizationAccountOperations, + startOrganizationAccountConnection, +} from '@/lib/credential-groups/application/organization-accounts' +import { createCredentialGroupInternalErrorPolicy } from '@/app/api/workspaces/[id]/credential-groups/error-policy' + +export const POST = defineInternalJsonRoute({ + contract: startOrganizationAccountConnectionContract, + auth: internalSessionAuth, + operation: organizationAccountOperations.connect, + rateLimit: internalRateLimits.none({ + reason: 'Bounded current-member self-enrollment; no email delivery', + }), + errorPolicy: createCredentialGroupInternalErrorPolicy('Failed to connect account'), + mapInput: ({ params, body }) => ({ + organizationId: params.id, + optionId: body.optionId, + }), + useCase: startOrganizationAccountConnection, +}) diff --git a/apps/sim/app/api/organizations/[id]/connected-accounts/databricks/route.ts b/apps/sim/app/api/organizations/[id]/connected-accounts/databricks/route.ts new file mode 100644 index 00000000000..ef9d7e49571 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/connected-accounts/databricks/route.ts @@ -0,0 +1,40 @@ +import { + configureOrganizationMcpContract, + getOrganizationDatabricksSetupContract, +} from '@/lib/api/contracts/organization-accounts' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + configureOrganizationMcp, + configureOrganizationMcpOperation, +} from '@/lib/credential-groups/application/configure-organization-mcp' +import { + getOrganizationDatabricksSetup, + getOrganizationDatabricksSetupOperation, +} from '@/lib/credential-groups/application/organization-databricks-setup' + +export const GET = defineInternalJsonRoute({ + contract: getOrganizationDatabricksSetupContract, + auth: internalSessionAuth, + operation: getOrganizationDatabricksSetupOperation, + rateLimit: internalRateLimits.user({ bucketName: 'organization-databricks-setup' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ organizationId: params.id }), + useCase: getOrganizationDatabricksSetup, + present: ({ server }) => ({ server }), +}) + +export const PUT = defineInternalJsonRoute({ + contract: configureOrganizationMcpContract, + auth: internalSessionAuth, + operation: configureOrganizationMcpOperation, + rateLimit: internalRateLimits.user({ bucketName: 'organization-databricks-setup' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ organizationId: params.id, ...body }), + useCase: configureOrganizationMcp, + present: ({ mcpServer }) => ({ mcpServer }), +}) diff --git a/apps/sim/app/api/organizations/[id]/connected-accounts/indexing/route.ts b/apps/sim/app/api/organizations/[id]/connected-accounts/indexing/route.ts new file mode 100644 index 00000000000..d79a286c00f --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/connected-accounts/indexing/route.ts @@ -0,0 +1,22 @@ +import { updateOrganizationAccountIndexingContract } from '@/lib/api/contracts/organization-accounts' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + updateOrganizationAccountIndexing, + updateOrganizationAccountIndexingOperation, +} from '@/lib/credential-groups/application/organization-account-indexing' + +export const PUT = defineInternalJsonRoute({ + contract: updateOrganizationAccountIndexingContract, + auth: internalSessionAuth, + operation: updateOrganizationAccountIndexingOperation, + rateLimit: internalRateLimits.user({ bucketName: 'organization-account-indexing' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ organizationId: params.id, ...body }), + useCase: updateOrganizationAccountIndexing, + present: ({ enabled, knowledgeBaseIds }) => ({ enabled, knowledgeBaseIds }), +}) diff --git a/apps/sim/app/api/organizations/[id]/connected-accounts/mcp-providers/[connectorId]/route.ts b/apps/sim/app/api/organizations/[id]/connected-accounts/mcp-providers/[connectorId]/route.ts new file mode 100644 index 00000000000..54055821f39 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/connected-accounts/mcp-providers/[connectorId]/route.ts @@ -0,0 +1,22 @@ +import { removeOrganizationAccountMcpProviderContract } from '@/lib/api/contracts/organization-accounts' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + organizationAccountManagementOperations, + removeOrganizationAccountMcpProvider, +} from '@/lib/credential-groups/application/organization-account-management' + +export const DELETE = defineInternalJsonRoute({ + contract: removeOrganizationAccountMcpProviderContract, + auth: internalSessionAuth, + operation: organizationAccountManagementOperations.removeMcp, + rateLimit: internalRateLimits.user({ bucketName: 'organization-connected-accounts' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ organizationId: params.id, connectorId: params.connectorId }), + useCase: removeOrganizationAccountMcpProvider, + present: () => ({ success: true as const }), +}) diff --git a/apps/sim/app/api/organizations/[id]/connected-accounts/mcp-providers/route.ts b/apps/sim/app/api/organizations/[id]/connected-accounts/mcp-providers/route.ts new file mode 100644 index 00000000000..101a8e6c3f7 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/connected-accounts/mcp-providers/route.ts @@ -0,0 +1,22 @@ +import { addOrganizationAccountMcpProviderContract } from '@/lib/api/contracts/organization-accounts' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + addOrganizationAccountMcpProvider, + organizationAccountManagementOperations, +} from '@/lib/credential-groups/application/organization-account-management' + +export const POST = defineInternalJsonRoute({ + contract: addOrganizationAccountMcpProviderContract, + auth: internalSessionAuth, + operation: organizationAccountManagementOperations.addMcp, + rateLimit: internalRateLimits.user({ bucketName: 'organization-connected-accounts' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ organizationId: params.id, ...body }), + useCase: addOrganizationAccountMcpProvider, + present: ({ mcpServer }) => ({ mcpServer }), +}) diff --git a/apps/sim/app/api/organizations/[id]/connected-accounts/people/[enrollmentId]/resend/route.ts b/apps/sim/app/api/organizations/[id]/connected-accounts/people/[enrollmentId]/resend/route.ts new file mode 100644 index 00000000000..838defa8935 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/connected-accounts/people/[enrollmentId]/resend/route.ts @@ -0,0 +1,22 @@ +import { resendOrganizationAccountInvitationContract } from '@/lib/api/contracts/organization-accounts' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + organizationAccountManagementOperations, + resendOrganizationAccountInvitation, +} from '@/lib/credential-groups/application/organization-account-management' + +export const POST = defineInternalJsonRoute({ + contract: resendOrganizationAccountInvitationContract, + auth: internalSessionAuth, + operation: organizationAccountManagementOperations.resend, + rateLimit: internalRateLimits.user({ bucketName: 'organization-connected-accounts' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ organizationId: params.id, enrollmentId: params.enrollmentId }), + useCase: resendOrganizationAccountInvitation, + present: ({ credentialGroupEnrollment }) => ({ credentialGroupEnrollment }), +}) diff --git a/apps/sim/app/api/organizations/[id]/connected-accounts/people/[enrollmentId]/route.ts b/apps/sim/app/api/organizations/[id]/connected-accounts/people/[enrollmentId]/route.ts new file mode 100644 index 00000000000..936ad572ccb --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/connected-accounts/people/[enrollmentId]/route.ts @@ -0,0 +1,22 @@ +import { revokeOrganizationAccountEnrollmentContract } from '@/lib/api/contracts/organization-accounts' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + organizationAccountManagementOperations, + revokeOrganizationAccountEnrollment, +} from '@/lib/credential-groups/application/organization-account-management' + +export const DELETE = defineInternalJsonRoute({ + contract: revokeOrganizationAccountEnrollmentContract, + auth: internalSessionAuth, + operation: organizationAccountManagementOperations.revoke, + rateLimit: internalRateLimits.user({ bucketName: 'organization-connected-accounts' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ organizationId: params.id, enrollmentId: params.enrollmentId }), + useCase: revokeOrganizationAccountEnrollment, + present: ({ credentialGroupEnrollment }) => ({ credentialGroupEnrollment }), +}) diff --git a/apps/sim/app/api/organizations/[id]/connected-accounts/people/route.ts b/apps/sim/app/api/organizations/[id]/connected-accounts/people/route.ts new file mode 100644 index 00000000000..61e8cd3fd0e --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/connected-accounts/people/route.ts @@ -0,0 +1,36 @@ +import { + inviteOrganizationAccountPeopleContract, + listOrganizationAccountPeopleContract, +} from '@/lib/api/contracts/organization-accounts' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + inviteOrganizationAccountPeople, + listOrganizationAccountPeople, + organizationAccountManagementOperations, +} from '@/lib/credential-groups/application/organization-account-management' + +export const GET = defineInternalJsonRoute({ + contract: listOrganizationAccountPeopleContract, + auth: internalSessionAuth, + operation: organizationAccountManagementOperations.people, + rateLimit: internalRateLimits.user({ bucketName: 'organization-connected-accounts' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, query }) => ({ organizationId: params.id, ...query }), + useCase: listOrganizationAccountPeople, +}) + +export const POST = defineInternalJsonRoute({ + contract: inviteOrganizationAccountPeopleContract, + auth: internalSessionAuth, + operation: organizationAccountManagementOperations.invite, + rateLimit: internalRateLimits.user({ bucketName: 'organization-connected-accounts' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ organizationId: params.id, ...body }), + useCase: inviteOrganizationAccountPeople, + present: ({ results, sentCount, failedCount }) => ({ results, sentCount, failedCount }), +}) diff --git a/apps/sim/app/api/organizations/[id]/connected-accounts/route.ts b/apps/sim/app/api/organizations/[id]/connected-accounts/route.ts new file mode 100644 index 00000000000..afa8338b32b --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/connected-accounts/route.ts @@ -0,0 +1,43 @@ +import { + ensureOrganizationAccountsContract, + getOrganizationAccountsContract, +} from '@/lib/api/contracts/organization-accounts' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + ensureOrganizationAccounts, + getOrganizationAccountsSettings, + organizationAccountOperations, +} from '@/lib/credential-groups/application/organization-accounts' +import { createCredentialGroupInternalErrorPolicy } from '@/app/api/workspaces/[id]/credential-groups/error-policy' + +const errorPolicy = createCredentialGroupInternalErrorPolicy( + 'Failed to load connected accounts', + 'Organization not found' +) +export const GET = defineInternalJsonRoute({ + contract: getOrganizationAccountsContract, + auth: internalSessionAuth, + operation: organizationAccountOperations.read, + rateLimit: internalRateLimits.none({ + reason: 'Authenticated organization account metadata read', + }), + errorPolicy, + mapInput: ({ params }) => ({ organizationId: params.id }), + useCase: getOrganizationAccountsSettings, +}) +export const POST = defineInternalJsonRoute({ + contract: ensureOrganizationAccountsContract, + auth: internalSessionAuth, + operation: organizationAccountOperations.ensure, + rateLimit: internalRateLimits.none({ + reason: 'Idempotent administrator account-container setup', + }), + errorPolicy, + mapInput: ({ params, body }) => ({ organizationId: params.id, ...body }), + useCase: ensureOrganizationAccounts, + present: ({ credentialGroup }) => ({ credentialGroup }), +}) diff --git a/apps/sim/app/api/organizations/[id]/connected-accounts/workspace-access/route.ts b/apps/sim/app/api/organizations/[id]/connected-accounts/workspace-access/route.ts new file mode 100644 index 00000000000..8ae3315a131 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/connected-accounts/workspace-access/route.ts @@ -0,0 +1,40 @@ +import { + getOrganizationAccountWorkspaceAccessContract, + updateOrganizationAccountWorkspaceAccessContract, +} from '@/lib/api/contracts/organization-accounts' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + getOrganizationAccountWorkspaceAccess, + organizationAccountAccessOperations, + updateOrganizationAccountWorkspaceAccess, +} from '@/lib/credential-groups/application/organization-access' + +export const GET = defineInternalJsonRoute({ + contract: getOrganizationAccountWorkspaceAccessContract, + auth: internalSessionAuth, + operation: organizationAccountAccessOperations.read, + rateLimit: internalRateLimits.none({ + reason: 'Bounded administrator workspace access settings read', + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ organizationId: params.id }), + useCase: getOrganizationAccountWorkspaceAccess, +}) + +export const PUT = defineInternalJsonRoute({ + contract: updateOrganizationAccountWorkspaceAccessContract, + auth: internalSessionAuth, + operation: organizationAccountAccessOperations.update, + rateLimit: internalRateLimits.none({ + reason: 'Administrator policy revision protects bounded workspace access updates', + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ organizationId: params.id, ...body }), + useCase: updateOrganizationAccountWorkspaceAccess, + present: ({ revision, workspaceIds }) => ({ revision, workspaceIds }), +}) diff --git a/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts b/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts index 1afe2fb1731..50e265f208f 100644 --- a/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts +++ b/apps/sim/app/api/organizations/[id]/members/[memberId]/route.ts @@ -11,13 +11,19 @@ import { getSession } from '@/lib/auth' import { setActiveOrganizationForCurrentSession } from '@/lib/auth/active-organization' import { getOrganizationMemberUsageSnapshot } from '@/lib/billing/core/organization' import { + acquireOrganizationUserMutationLocks, removeExternalUserFromOrganizationWorkspaces, removeUserFromOrganization, WORKSPACE_BILLING_ACCOUNT_REMOVAL_ERROR, } from '@/lib/billing/organizations/membership' import { reconcileOrganizationSeats } from '@/lib/billing/organizations/seats' +import { ForbiddenOperationError } from '@/lib/core/application' +import { OrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { isRetryableTransactionError } from '@/lib/db/transaction' +import { changeMemberRoleTx } from '@/lib/organizations/members/lifecycle' import { captureServerEvent } from '@/lib/posthog/server' +import { assertMembershipNotScimManaged } from '@/ee/scim/lib/managed-membership' const logger = createLogger('OrganizationMemberAPI') @@ -208,16 +214,27 @@ export const PUT = withRouteHandler( ) } - const updatedMember = await db - .update(member) - .set({ role }) - .where(and(eq(member.organizationId, organizationId), eq(member.userId, memberId))) - .returning() - - if (updatedMember.length === 0) { - return NextResponse.json({ error: 'Failed to update member role' }, { status: 500 }) - } + /** + * The member is re-read under the organization's mutation lock, so a + * concurrent promotion to owner — or a directory provisioning this very + * member — cannot slip between the checks and the write. When the + * organization has made its identity provider the source of truth, a role + * set here is reverted by the next sync; refusing says so. + */ + const roleChange = await db.transaction(async (tx) => { + await acquireOrganizationUserMutationLocks(tx, { + userId: memberId, + organizationIds: [organizationId], + }) + await assertMembershipNotScimManaged({ organizationId, userId: memberId, executor: tx }) + return changeMemberRoleTx(tx, { organizationId, userId: memberId, role }) + }) + /** + * The audit row and analytics event fire whether or not the role actually + * moved, exactly as this route did before the write went through the + * shared primitive. Callers assert on those side effects. + */ logger.info('Organization member role updated', { organizationId, memberId, @@ -254,13 +271,33 @@ export const PUT = withRouteHandler( success: true, message: 'Member role updated successfully', data: { - id: updatedMember[0].id, - userId: updatedMember[0].userId, - role: updatedMember[0].role, + id: targetMember[0].id, + userId: targetMember[0].userId, + role: roleChange.changed ? roleChange.to : roleChange.role, updatedBy: session.user.id, }, }) } catch (error) { + if (error instanceof ForbiddenOperationError) { + return NextResponse.json( + { error: error.message, details: { code: error.detailCode } }, + { status: 403 } + ) + } + if (error instanceof OrchestrationError) { + return NextResponse.json( + { error: error.message }, + { status: statusForOrchestrationError(error.code) } + ) + } + /** The role change now serializes on the organization lock; a timeout is "retry", not a fault. */ + if (isRetryableTransactionError(error)) { + return NextResponse.json( + { error: 'The organization is busy; retry in a moment' }, + { status: 409 } + ) + } + logger.error('Failed to update organization member role', { organizationId: (await context.params).id, memberId: (await context.params).memberId, @@ -406,6 +443,7 @@ export const DELETE = withRouteHandler( userId: targetUserId, organizationId, memberId: targetMember[0].id, + spareSessionToken: session.session.token, }) if (!result.success) { diff --git a/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/bulk/route.ts b/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/bulk/route.ts index 7cec2121fb2..5f7257295d2 100644 --- a/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/bulk/route.ts +++ b/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/bulk/route.ts @@ -10,15 +10,17 @@ import { bulkAddPermissionGroupMembersContract } from '@/lib/api/contracts/permi import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + findScopeConflicts, + type ScopeConflict, +} from '@/lib/permission-groups/application/group-membership' import { PERMISSION_GROUP_MEMBER_CONSTRAINTS } from '@/lib/permission-groups/constraints' import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks' import { authorizeOrgAccessControl, - findScopeConflicts, formatScopeConflictError, getGroupWorkspaces, loadGroupInOrganization, - type ScopeConflict, } from '@/app/api/organizations/[id]/permission-groups/utils' const logger = createLogger('OrganizationPermissionGroupBulkMembers') diff --git a/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/route.ts b/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/route.ts index 27a95cf0c7f..b2a6ee6fc42 100644 --- a/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/route.ts +++ b/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/members/route.ts @@ -10,19 +10,21 @@ import { addPermissionGroupMemberContract } from '@/lib/api/contracts/permission import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + type AllMembersConflict, + findAllMembersWorkspaceConflict, + findScopeConflicts, + type ScopeConflict, +} from '@/lib/permission-groups/application/group-membership' import { PERMISSION_GROUP_MEMBER_CONSTRAINTS } from '@/lib/permission-groups/constraints' import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks' import { isOrganizationMember } from '@/lib/workspaces/permissions/utils' import { - type AllMembersConflict, authorizeOrgAccessControl, - findAllMembersWorkspaceConflict, - findScopeConflicts, formatAllMembersConflictError, formatScopeConflictError, getGroupWorkspaces, loadGroupInOrganization, - type ScopeConflict, } from '@/app/api/organizations/[id]/permission-groups/utils' const logger = createLogger('OrganizationPermissionGroupMembers') @@ -281,7 +283,7 @@ export const DELETE = withRouteHandler( throw new Error('MEMBER_NOT_FOUND') } - if (!lockedGroup.isDefault) { + if (!lockedGroup.isDefault && lockedGroup.membershipMode === 'inherit') { const [memberCountRow] = await tx .select({ value: count() }) .from(permissionGroupMember) diff --git a/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/route.test.ts b/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/route.test.ts new file mode 100644 index 00000000000..02948dc1ee5 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/route.test.ts @@ -0,0 +1,153 @@ +/** + * @vitest-environment node + */ +import { db } from '@sim/db' +import { permissionGroup } from '@sim/db/schema' +import { + authMockFns, + createMockRequest, + dbChainMockFns, + queueTableRows, + resetDbChainMock, +} from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { UpdatePermissionGroupBody } from '@/lib/api/contracts/permission-groups' + +const mocks = vi.hoisted(() => ({ + acquireLock: vi.fn(), + authorize: vi.fn(), + loadGroup: vi.fn(), +})) + +vi.mock('@/lib/permission-groups/locks', () => ({ + acquirePermissionGroupOrgLock: mocks.acquireLock, +})) + +vi.mock('@/lib/permission-groups/application/group-membership', () => ({ + findAllMembersWorkspaceConflict: vi.fn(), + findScopeConflicts: vi.fn(), +})) + +vi.mock('@/app/api/organizations/[id]/permission-groups/utils', () => ({ + authorizeOrgAccessControl: mocks.authorize, + loadGroupInOrganization: mocks.loadGroup, + findWorkspacesNotInOrganization: vi.fn(), + formatAllMembersConflictError: vi.fn(), + formatScopeConflictError: vi.fn(), + getGroupWorkspaces: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + recordAudit: vi.fn(), + AuditAction: { PERMISSION_GROUP_UPDATED: 'permission_group.updated' }, + AuditResourceType: { PERMISSION_GROUP: 'permission_group' }, +})) + +import { PUT } from '@/app/api/organizations/[id]/permission-groups/[groupId]/route' + +const ORGANIZATION_ID = 'org-1' +const GROUP_ID = 'group-1' +const GROUP = { + id: GROUP_ID, + organizationId: ORGANIZATION_ID, + name: 'Default', + description: null, + isDefault: true, + config: { disableOAuthAppAccess: false }, +} + +async function updateUnderLock(body: UpdatePermissionGroupBody) { + const lockEntered = Promise.withResolvers() + const lockReleased = Promise.withResolvers() + mocks.acquireLock.mockImplementationOnce(() => { + lockEntered.resolve(true) + return lockReleased.promise + }) + + const pendingResponse = PUT(createMockRequest('PUT', body), { + params: Promise.resolve({ id: ORGANIZATION_ID, groupId: GROUP_ID }), + }) + try { + expect(await Promise.race([lockEntered.promise, pendingResponse.then(() => false)])).toBe(true) + expect(mocks.acquireLock).toHaveBeenCalledExactlyOnceWith(db, ORGANIZATION_ID) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + } finally { + lockReleased.resolve() + } + + const response = await pendingResponse + expect(response.status).toBe(200) + expect(dbChainMockFns.update).toHaveBeenCalledExactlyOnceWith(permissionGroup) + expect(mocks.loadGroup).toHaveBeenLastCalledWith(GROUP_ID, ORGANIZATION_ID, db) + return response +} + +describe('permission group PUT policy serialization', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'admin-1' } }) + mocks.authorize.mockResolvedValue(null) + mocks.loadGroup.mockResolvedValue(GROUP) + }) + + it('locks a config-only update and writes the requested OAuth restriction', async () => { + queueTableRows(permissionGroup, [{ ...GROUP, config: { disableOAuthAppAccess: true } }]) + + await updateUnderLock({ config: { disableOAuthAppAccess: true } }) + + expect(dbChainMockFns.set).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ config: expect.objectContaining({ disableOAuthAppAccess: true }) }) + ) + }) + + it.each([{ name: 'Renamed group' }, { description: 'Updated description' }])( + 'locks metadata-only update %j without restoring stale policy', + async (metadata) => { + if ('name' in metadata) queueTableRows(permissionGroup, []) + queueTableRows(permissionGroup, [ + { ...GROUP, ...metadata, config: { disableOAuthAppAccess: true } }, + ]) + + const response = await updateUnderLock(metadata) + + expect(dbChainMockFns.set).toHaveBeenCalledExactlyOnceWith(expect.objectContaining(metadata)) + expect(dbChainMockFns.set.mock.calls[0][0]).not.toHaveProperty('config') + await expect(response.json()).resolves.toMatchObject({ + permissionGroup: { config: { disableOAuthAppAccess: true } }, + }) + } + ) + + it('merges a config patch with the policy reloaded under the lock', async () => { + mocks.loadGroup + .mockResolvedValueOnce(GROUP) + .mockResolvedValueOnce({ ...GROUP, config: { disableOAuthAppAccess: true } }) + queueTableRows(permissionGroup, [ + { ...GROUP, config: { disableOAuthAppAccess: true, disableCliAccess: true } }, + ]) + + await updateUnderLock({ config: { disableCliAccess: true } }) + + expect(dbChainMockFns.set).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + config: expect.objectContaining({ disableOAuthAppAccess: true, disableCliAccess: true }), + }) + ) + }) + + it('does not write when the group disappears before the locked reload', async () => { + mocks.loadGroup.mockResolvedValueOnce(GROUP).mockResolvedValueOnce(null) + mocks.acquireLock.mockResolvedValueOnce(undefined) + + const response = await PUT(createMockRequest('PUT', { description: 'Updated description' }), { + params: Promise.resolve({ id: ORGANIZATION_ID, groupId: GROUP_ID }), + }) + + expect(response.status).toBe(404) + await expect(response.json()).resolves.toEqual({ error: 'Permission group not found' }) + expect(mocks.acquireLock).toHaveBeenCalledExactlyOnceWith(db, ORGANIZATION_ID) + expect(mocks.loadGroup).toHaveBeenLastCalledWith(GROUP_ID, ORGANIZATION_ID, db) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/route.ts b/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/route.ts index fcf5b0ee280..a245f86f430 100644 --- a/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/route.ts +++ b/apps/sim/app/api/organizations/[id]/permission-groups/[groupId]/route.ts @@ -10,6 +10,12 @@ import { updatePermissionGroupContract } from '@/lib/api/contracts/permission-gr import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + type AllMembersConflict, + findAllMembersWorkspaceConflict, + findScopeConflicts, + type ScopeConflict, +} from '@/lib/permission-groups/application/group-membership' import { PERMISSION_GROUP_CONSTRAINTS } from '@/lib/permission-groups/constraints' import { type PermissionGroupConfig, @@ -17,16 +23,12 @@ import { } from '@/lib/permission-groups/fields' import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks' import { - type AllMembersConflict, authorizeOrgAccessControl, - findAllMembersWorkspaceConflict, - findScopeConflicts, findWorkspacesNotInOrganization, formatAllMembersConflictError, formatScopeConflictError, getGroupWorkspaces, loadGroupInOrganization, - type ScopeConflict, } from '@/app/api/organizations/[id]/permission-groups/utils' const logger = createLogger('OrganizationPermissionGroup') @@ -110,11 +112,6 @@ export const PUT = withRouteHandler( } } - const currentConfig = parsePermissionGroupConfig(group.config) - const newConfig: PermissionGroupConfig = updates.config - ? { ...currentConfig, ...updates.config } - : currentConfig - // Demoting the org default with no new scope: it becomes a non-default // group with no workspaces (inert) until an admin re-scopes it. The client // sends only `isDefault: false`, so this never forwards a workspace list. @@ -167,19 +164,20 @@ export const PUT = withRouteHandler( const now = new Date() await db.transaction(async (tx) => { + await acquirePermissionGroupOrgLock(tx, organizationId) + const currentGroup = await loadGroupInOrganization(id, organizationId, tx) + if (!currentGroup) throw new Error('GROUP_NOT_FOUND') + const newConfig: PermissionGroupConfig | undefined = updates.config + ? { ...parsePermissionGroupConfig(currentGroup.config), ...updates.config } + : undefined + // For a specific-scope group the target workspaces are the request's // explicit ids, or — when omitted ("keep current") — the group's current // workspaces read under the lock so the conflict check and write share // one snapshot. let resolvedWorkspaceIds: string[] = [] - // When the scope changes, serialize against other permission-group writes - // for this org and re-check membership conflicts atomically with the - // write, so a concurrent member add (or scope change) can't slip a user - // into two groups that overlap on a workspace. if (scopeProvided) { - await acquirePermissionGroupOrgLock(tx, organizationId) - if (!effectiveIsDefault) { // May resolve to an empty list — a non-default group is allowed to // target zero workspaces (governs nothing). The write below deletes @@ -241,7 +239,7 @@ export const PUT = withRouteHandler( ...(updates.name !== undefined && { name: updates.name }), ...(updates.description !== undefined && { description: updates.description }), ...(updates.isDefault !== undefined && { isDefault: updates.isDefault }), - config: newConfig, + ...(newConfig !== undefined && { config: newConfig }), updatedAt: now, }) .where(eq(permissionGroup.id, id)) @@ -300,6 +298,9 @@ export const PUT = withRouteHandler( }, }) } catch (error) { + if (error instanceof Error && error.message === 'GROUP_NOT_FOUND') { + return NextResponse.json({ error: 'Permission group not found' }, { status: 404 }) + } if (error instanceof Error && error.message === 'SCOPE_CONFLICT') { return NextResponse.json( { error: formatScopeConflictError(scopeConflicts) }, diff --git a/apps/sim/app/api/organizations/[id]/permission-groups/route.ts b/apps/sim/app/api/organizations/[id]/permission-groups/route.ts index dd8e9400fe3..42cfc1ae1a4 100644 --- a/apps/sim/app/api/organizations/[id]/permission-groups/route.ts +++ b/apps/sim/app/api/organizations/[id]/permission-groups/route.ts @@ -15,6 +15,10 @@ import { createPermissionGroupContract } from '@/lib/api/contracts/permission-gr import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + type AllMembersConflict, + findAllMembersWorkspaceConflict, +} from '@/lib/permission-groups/application/group-membership' import { PERMISSION_GROUP_CONSTRAINTS } from '@/lib/permission-groups/constraints' import { DEFAULT_PERMISSION_GROUP_CONFIG, @@ -23,9 +27,7 @@ import { } from '@/lib/permission-groups/fields' import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks' import { - type AllMembersConflict, authorizeOrgAccessControl, - findAllMembersWorkspaceConflict, findWorkspacesNotInOrganization, formatAllMembersConflictError, getWorkspacesForGroups, diff --git a/apps/sim/app/api/organizations/[id]/permission-groups/utils.test.ts b/apps/sim/app/api/organizations/[id]/permission-groups/utils.test.ts index 6e762e3bd2a..62c0ddb18cc 100644 --- a/apps/sim/app/api/organizations/[id]/permission-groups/utils.test.ts +++ b/apps/sim/app/api/organizations/[id]/permission-groups/utils.test.ts @@ -1,8 +1,7 @@ /** * @vitest-environment node */ -import { permissionGroup, permissionGroupMember } from '@sim/db/schema' -import { queueTableRows, resetDbChainMock } from '@sim/testing' +import { resetDbChainMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { mockIsOrganizationAdminOrOwner, mockIsOrganizationOnEnterprisePlan } = vi.hoisted(() => ({ @@ -18,11 +17,7 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ isOrganizationAdminOrOwner: mockIsOrganizationAdminOrOwner, })) -import { - authorizeOrgAccessControl, - findAllMembersWorkspaceConflict, - findScopeConflicts, -} from '@/app/api/organizations/[id]/permission-groups/utils' +import { authorizeOrgAccessControl } from '@/app/api/organizations/[id]/permission-groups/utils' afterAll(resetDbChainMock) @@ -66,111 +61,3 @@ describe('authorizeOrgAccessControl', () => { expect(response).toBeNull() }) }) - -describe('findScopeConflicts', () => { - beforeEach(() => { - vi.clearAllMocks() - resetDbChainMock() - }) - - const baseParams = { - organizationId: 'org-1', - excludeGroupId: 'group-1', - workspaceIds: ['ws-1'], - candidateUserIds: ['user-1'], - } - - const conflictRow = (userId: string, otherGroupName = 'Marketing') => ({ - userId, - userName: 'User One', - userEmail: `${userId}@example.com`, - otherGroupId: 'group-2', - otherGroupName, - }) - - it('returns no conflicts when there are no candidate users', async () => { - queueTableRows(permissionGroupMember, [conflictRow('user-1')]) - - const conflicts = await findScopeConflicts({ ...baseParams, candidateUserIds: [] }) - - expect(conflicts).toEqual([]) - }) - - it('returns no conflicts when there are no target workspaces', async () => { - queueTableRows(permissionGroupMember, [conflictRow('user-1')]) - - const conflicts = await findScopeConflicts({ ...baseParams, workspaceIds: [] }) - - expect(conflicts).toEqual([]) - }) - - it('flags a candidate already in another group that shares a workspace', async () => { - queueTableRows(permissionGroupMember, [conflictRow('user-1')]) - - const conflicts = await findScopeConflicts(baseParams) - - expect(conflicts.map((c) => c.userId)).toEqual(['user-1']) - expect(conflicts[0].conflictingGroupName).toBe('Marketing') - }) - - it('returns at most one conflict per user', async () => { - queueTableRows(permissionGroupMember, [ - conflictRow('user-1', 'Marketing'), - conflictRow('user-1', 'Sales'), - ]) - - const conflicts = await findScopeConflicts(baseParams) - - expect(conflicts).toHaveLength(1) - expect(conflicts[0].conflictingGroupName).toBe('Marketing') - }) - - it('returns no conflicts when the query finds no overlapping memberships', async () => { - const conflicts = await findScopeConflicts(baseParams) - - expect(conflicts).toEqual([]) - }) -}) - -describe('findAllMembersWorkspaceConflict', () => { - beforeEach(() => { - vi.clearAllMocks() - resetDbChainMock() - }) - - const baseParams = { - organizationId: 'org-1', - excludeGroupId: 'group-1', - workspaceIds: ['ws-1', 'ws-2'], - } - - it('returns null when there are no target workspaces', async () => { - queueTableRows(permissionGroup, [ - { conflictingGroupId: 'group-2', conflictingGroupName: 'Marketing', workspaceName: 'Acme' }, - ]) - - const conflict = await findAllMembersWorkspaceConflict({ ...baseParams, workspaceIds: [] }) - - expect(conflict).toBeNull() - }) - - it('returns the conflicting all-members group sharing a workspace', async () => { - queueTableRows(permissionGroup, [ - { conflictingGroupId: 'group-2', conflictingGroupName: 'Marketing', workspaceName: 'Acme' }, - ]) - - const conflict = await findAllMembersWorkspaceConflict(baseParams) - - expect(conflict).toEqual({ - conflictingGroupId: 'group-2', - conflictingGroupName: 'Marketing', - workspaceName: 'Acme', - }) - }) - - it('returns null when no other all-members group targets the workspaces', async () => { - const conflict = await findAllMembersWorkspaceConflict(baseParams) - - expect(conflict).toBeNull() - }) -}) diff --git a/apps/sim/app/api/organizations/[id]/permission-groups/utils.ts b/apps/sim/app/api/organizations/[id]/permission-groups/utils.ts index 9b26470f470..f96ac8dacf7 100644 --- a/apps/sim/app/api/organizations/[id]/permission-groups/utils.ts +++ b/apps/sim/app/api/organizations/[id]/permission-groups/utils.ts @@ -1,15 +1,13 @@ import { db } from '@sim/db' -import { - permissionGroup, - permissionGroupMember, - permissionGroupWorkspace, - user, - workspace, -} from '@sim/db/schema' -import { and, asc, eq, inArray, ne, sql } from 'drizzle-orm' +import { permissionGroup, permissionGroupWorkspace, workspace } from '@sim/db/schema' +import { and, asc, eq, inArray } from 'drizzle-orm' import { NextResponse } from 'next/server' import { isOrganizationOnEnterprisePlan } from '@/lib/billing' import type { DbOrTx } from '@/lib/db/types' +import type { + AllMembersConflict, + ScopeConflict, +} from '@/lib/permission-groups/application/group-membership' import { isOrganizationAdminOrOwner } from '@/lib/workspaces/permissions/utils' /** A workspace reference (id + display name). */ @@ -58,6 +56,7 @@ export async function loadGroupInOrganization( createdAt: permissionGroup.createdAt, updatedAt: permissionGroup.updatedAt, isDefault: permissionGroup.isDefault, + membershipMode: permissionGroup.membershipMode, }) .from(permissionGroup) .where(and(eq(permissionGroup.id, groupId), eq(permissionGroup.organizationId, organizationId))) @@ -129,125 +128,6 @@ export async function listOrganizationWorkspaces(organizationId: string): Promis } /** A member whose other group membership would conflict with a candidate scope. */ -export interface ScopeConflict { - userId: string - userName: string | null - userEmail: string | null - /** The group the member already belongs to that causes the conflict. */ - conflictingGroupId: string - conflictingGroupName: string -} - -/** - * Which of `candidateUserIds` would be governed by two groups on the same - * workspace: each is already an explicit member of another non-default group - * that shares one of `workspaceIds`. The candidate group (`excludeGroupId`) and - * the org default group are ignored — the default never governs through - * membership. Returns at most one conflict per user. - */ -export async function findScopeConflicts( - params: { - organizationId: string - excludeGroupId: string - workspaceIds: string[] - candidateUserIds: string[] - }, - executor: DbOrTx = db -): Promise { - const { organizationId, excludeGroupId, workspaceIds, candidateUserIds } = params - if (candidateUserIds.length === 0 || workspaceIds.length === 0) return [] - - const rows = await executor - .select({ - userId: permissionGroupMember.userId, - userName: user.name, - userEmail: user.email, - otherGroupId: permissionGroup.id, - otherGroupName: permissionGroup.name, - }) - .from(permissionGroupMember) - .innerJoin(permissionGroup, eq(permissionGroupMember.permissionGroupId, permissionGroup.id)) - .innerJoin( - permissionGroupWorkspace, - eq(permissionGroupWorkspace.permissionGroupId, permissionGroup.id) - ) - .leftJoin(user, eq(permissionGroupMember.userId, user.id)) - .where( - and( - eq(permissionGroupMember.organizationId, organizationId), - inArray(permissionGroupMember.userId, candidateUserIds), - ne(permissionGroupMember.permissionGroupId, excludeGroupId), - eq(permissionGroup.isDefault, false), - inArray(permissionGroupWorkspace.workspaceId, workspaceIds) - ) - ) - - const conflictByUser = new Map() - for (const row of rows) { - if (conflictByUser.has(row.userId)) continue - conflictByUser.set(row.userId, { - userId: row.userId, - userName: row.userName, - userEmail: row.userEmail, - conflictingGroupId: row.otherGroupId, - conflictingGroupName: row.otherGroupName, - }) - } - return Array.from(conflictByUser.values()) -} - -/** An existing all-members group that already governs everyone in a shared workspace. */ -export interface AllMembersConflict { - conflictingGroupId: string - conflictingGroupName: string - workspaceName: string -} - -/** - * For a group that will govern *all members* of `workspaceIds` (a non-default - * group with no explicit members), return the first other non-default - * all-members group already targeting one of those workspaces, or `null`. Two - * all-members groups on one workspace would both claim everyone there, so this - * is rejected at assignment time. The candidate group (`excludeGroupId`) is - * ignored. - */ -export async function findAllMembersWorkspaceConflict( - params: { organizationId: string; excludeGroupId: string; workspaceIds: string[] }, - executor: DbOrTx = db -): Promise { - const { organizationId, excludeGroupId, workspaceIds } = params - if (workspaceIds.length === 0) return null - - const [row] = await executor - .select({ - conflictingGroupId: permissionGroup.id, - conflictingGroupName: permissionGroup.name, - workspaceName: workspace.name, - }) - .from(permissionGroup) - .innerJoin( - permissionGroupWorkspace, - eq(permissionGroupWorkspace.permissionGroupId, permissionGroup.id) - ) - .innerJoin(workspace, eq(permissionGroupWorkspace.workspaceId, workspace.id)) - .where( - and( - eq(permissionGroup.organizationId, organizationId), - eq(permissionGroup.isDefault, false), - ne(permissionGroup.id, excludeGroupId), - inArray(permissionGroupWorkspace.workspaceId, workspaceIds), - sql`not exists ( - select 1 from ${permissionGroupMember} - where ${permissionGroupMember.permissionGroupId} = ${permissionGroup.id} - )` - ) - ) - .orderBy(asc(workspace.name)) - .limit(1) - - return row ?? null -} - /** * Human-readable 409 message for a scope/membership conflict, naming the member * and the group they already belong to that overlaps the requested workspaces. diff --git a/apps/sim/app/api/organizations/[id]/roster/route.test.ts b/apps/sim/app/api/organizations/[id]/roster/route.test.ts index 45b2ad9437a..8d1fc44d1dc 100644 --- a/apps/sim/app/api/organizations/[id]/roster/route.test.ts +++ b/apps/sim/app/api/organizations/[id]/roster/route.test.ts @@ -57,6 +57,7 @@ const MEMBER_ROWS = [ userName: 'Admin User', userEmail: 'admin@example.com', userImage: null, + userSuspendedAt: null, }, { memberId: 'member-reader', @@ -66,6 +67,7 @@ const MEMBER_ROWS = [ userName: 'Reader User', userEmail: 'reader@example.com', userImage: 'https://example.com/reader.png', + userSuspendedAt: null, }, ] @@ -118,6 +120,7 @@ describe('GET /api/organizations/[id]/roster', () => { name: 'Admin User', email: 'admin@example.com', image: null, + suspendedAt: null, workspaces: [], }, { @@ -128,6 +131,7 @@ describe('GET /api/organizations/[id]/roster', () => { name: 'Reader User', email: 'reader@example.com', image: 'https://example.com/reader.png', + suspendedAt: null, workspaces: [], }, ], @@ -167,6 +171,7 @@ describe('GET /api/organizations/[id]/roster', () => { userName: 'External User', userEmail: 'external@example.com', userImage: null, + userSuspendedAt: null, workspaceId: 'workspace-1', permission: 'read', createdAt: new Date('2026-03-01T00:00:00.000Z'), diff --git a/apps/sim/app/api/organizations/[id]/roster/route.ts b/apps/sim/app/api/organizations/[id]/roster/route.ts index a7d7bba1868..e4def40fdcb 100644 --- a/apps/sim/app/api/organizations/[id]/roster/route.ts +++ b/apps/sim/app/api/organizations/[id]/roster/route.ts @@ -83,6 +83,7 @@ export const GET = withRouteHandler( userName: user.name, userEmail: user.email, userImage: user.image, + userSuspendedAt: user.suspendedAt, }) .from(member) .innerJoin(user, eq(member.userId, user.id)) @@ -96,6 +97,7 @@ export const GET = withRouteHandler( name: row.userName, email: row.userEmail, image: row.userImage, + suspendedAt: row.userSuspendedAt?.toISOString() ?? null, workspaces: [] as RosterWorkspaceAccess[], })) @@ -189,6 +191,7 @@ export const GET = withRouteHandler( userName: user.name, userEmail: user.email, userImage: user.image, + userSuspendedAt: user.suspendedAt, workspaceId: permissions.entityId, permission: permissions.permissionType, createdAt: permissions.createdAt, @@ -218,6 +221,7 @@ export const GET = withRouteHandler( name: string email: string image: string | null + suspendedAt: string | null workspaces: RosterWorkspaceAccess[] } >() @@ -247,6 +251,7 @@ export const GET = withRouteHandler( name: row.userName, email: row.userEmail, image: row.userImage, + suspendedAt: row.userSuspendedAt?.toISOString() ?? null, workspaces: [workspaceAccess], }) } diff --git a/apps/sim/app/api/organizations/[id]/scim/activity/route.ts b/apps/sim/app/api/organizations/[id]/scim/activity/route.ts new file mode 100644 index 00000000000..05ddcb54075 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/scim/activity/route.ts @@ -0,0 +1,24 @@ +import { listScimActivityContract } from '@/lib/api/contracts/organization-scim' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { listScimActivity } from '@/ee/scim/lib/application/admin/connection' + +/** Recent provisioning requests, so a failing sync can be diagnosed from Sim. */ +export const GET = defineInternalJsonRoute({ + contract: listScimActivityContract, + auth: internalSessionAuth, + operation: listScimActivity.operation, + rateLimit: internalRateLimits.none({ + reason: 'Authenticated organization settings read, admission unchanged from its siblings.', + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, query }) => ({ + organizationId: params.id, + ...(query.limit !== undefined ? { limit: query.limit } : {}), + }), + useCase: listScimActivity, +}) diff --git a/apps/sim/app/api/organizations/[id]/scim/credentials/[credentialId]/route.ts b/apps/sim/app/api/organizations/[id]/scim/credentials/[credentialId]/route.ts new file mode 100644 index 00000000000..c81be93ca9a --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/scim/credentials/[credentialId]/route.ts @@ -0,0 +1,22 @@ +import { revokeScimCredentialContract } from '@/lib/api/contracts/organization-scim' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { revokeScimCredential } from '@/ee/scim/lib/application/admin/credentials' + +export const DELETE = defineInternalJsonRoute({ + contract: revokeScimCredentialContract, + auth: internalSessionAuth, + operation: revokeScimCredential.operation, + rateLimit: internalRateLimits.user({ bucketName: 'scim-credential-revoke' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ + organizationId: params.id, + credentialId: params.credentialId, + }), + useCase: revokeScimCredential, + present: ({ success }) => ({ success }), +}) diff --git a/apps/sim/app/api/organizations/[id]/scim/credentials/route.ts b/apps/sim/app/api/organizations/[id]/scim/credentials/route.ts new file mode 100644 index 00000000000..8c79e5fc297 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/scim/credentials/route.ts @@ -0,0 +1,26 @@ +import { issueScimCredentialContract } from '@/lib/api/contracts/organization-scim' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { issueScimCredential } from '@/ee/scim/lib/application/admin/credentials' + +/** Issues a bearer credential. The secret is returned once and never stored. */ +export const POST = defineInternalJsonRoute({ + contract: issueScimCredentialContract, + auth: internalSessionAuth, + operation: issueScimCredential.operation, + rateLimit: internalRateLimits.user({ + bucketName: 'scim-credential-issue', + config: { maxTokens: 10, refillRate: 5, refillIntervalMs: 60_000 }, + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ + organizationId: params.id, + ...(body.expiresInDays !== undefined ? { expiresInDays: body.expiresInDays } : {}), + }), + useCase: issueScimCredential, + present: ({ secret, credential }) => ({ secret, credential }), +}) diff --git a/apps/sim/app/api/organizations/[id]/scim/mappings/[mappingId]/route.ts b/apps/sim/app/api/organizations/[id]/scim/mappings/[mappingId]/route.ts new file mode 100644 index 00000000000..a73704e8865 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/scim/mappings/[mappingId]/route.ts @@ -0,0 +1,19 @@ +import { deleteScimGroupMappingContract } from '@/lib/api/contracts/organization-scim' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { deleteScimGroupMapping } from '@/ee/scim/lib/application/admin/mappings' + +export const DELETE = defineInternalJsonRoute({ + contract: deleteScimGroupMappingContract, + auth: internalSessionAuth, + operation: deleteScimGroupMapping.operation, + rateLimit: internalRateLimits.user({ bucketName: 'scim-mapping-delete' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ organizationId: params.id, mappingId: params.mappingId }), + useCase: deleteScimGroupMapping, + present: ({ success, reconciledUsers }) => ({ success, reconciledUsers }), +}) diff --git a/apps/sim/app/api/organizations/[id]/scim/mappings/route.ts b/apps/sim/app/api/organizations/[id]/scim/mappings/route.ts new file mode 100644 index 00000000000..f0931d1db52 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/scim/mappings/route.ts @@ -0,0 +1,38 @@ +import { + listScimGroupMappingsContract, + upsertScimGroupMappingContract, +} from '@/lib/api/contracts/organization-scim' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + listScimGroupMappings, + upsertScimGroupMapping, +} from '@/ee/scim/lib/application/admin/mappings' + +/** What each directory group means inside Sim. */ + +export const GET = defineInternalJsonRoute({ + contract: listScimGroupMappingsContract, + auth: internalSessionAuth, + operation: listScimGroupMappings.operation, + rateLimit: internalRateLimits.none({ + reason: 'Authenticated organization settings read, admission unchanged from its siblings.', + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ organizationId: params.id }), + useCase: listScimGroupMappings, +}) + +export const POST = defineInternalJsonRoute({ + contract: upsertScimGroupMappingContract, + auth: internalSessionAuth, + operation: upsertScimGroupMapping.operation, + rateLimit: internalRateLimits.user({ bucketName: 'scim-mapping-upsert' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ organizationId: params.id, ...body }), + useCase: upsertScimGroupMapping, +}) diff --git a/apps/sim/app/api/organizations/[id]/scim/reconcile/route.ts b/apps/sim/app/api/organizations/[id]/scim/reconcile/route.ts new file mode 100644 index 00000000000..09761cd6381 --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/scim/reconcile/route.ts @@ -0,0 +1,27 @@ +import { reconcileScimConnectionContract } from '@/lib/api/contracts/organization-scim' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { reconcileScimConnection } from '@/ee/scim/lib/application/admin/connection' + +/** + * Re-applies every group mapping to every provisioned user. + * + * Idempotent, so an administrator can run it after changing mappings without + * waiting for the scheduled pass. + */ +export const POST = defineInternalJsonRoute({ + contract: reconcileScimConnectionContract, + auth: internalSessionAuth, + operation: reconcileScimConnection.operation, + rateLimit: internalRateLimits.user({ + bucketName: 'scim-reconcile', + config: { maxTokens: 5, refillRate: 2, refillIntervalMs: 60_000 }, + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ organizationId: params.id }), + useCase: reconcileScimConnection, +}) diff --git a/apps/sim/app/api/organizations/[id]/scim/route.ts b/apps/sim/app/api/organizations/[id]/scim/route.ts new file mode 100644 index 00000000000..96835be4afa --- /dev/null +++ b/apps/sim/app/api/organizations/[id]/scim/route.ts @@ -0,0 +1,48 @@ +import { + configureScimConnectionContract, + getScimConnectionContract, +} from '@/lib/api/contracts/organization-scim' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + configureScimConnection, + getScimConnection, +} from '@/ee/scim/lib/application/admin/connection' + +/** + * The organization's directory-provisioning connection. + * + * Session-authenticated settings surface, distinct from the SCIM protocol + * endpoints under `/api/scim/v2` that the identity provider itself calls. + */ + +export const GET = defineInternalJsonRoute({ + contract: getScimConnectionContract, + auth: internalSessionAuth, + operation: getScimConnection.operation, + rateLimit: internalRateLimits.none({ + reason: 'Authenticated organization settings read, admission unchanged from its siblings.', + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ organizationId: params.id }), + useCase: getScimConnection, +}) + +export const PUT = defineInternalJsonRoute({ + contract: configureScimConnectionContract, + auth: internalSessionAuth, + operation: configureScimConnection.operation, + rateLimit: internalRateLimits.user({ bucketName: 'scim-configure' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ + organizationId: params.id, + ...(body.status !== undefined ? { status: body.status } : {}), + ...(body.settings !== undefined ? { settings: body.settings } : {}), + }), + useCase: configureScimConnection, + present: ({ connection }) => ({ connection }), +}) diff --git a/apps/sim/app/api/organizations/[id]/transfer-ownership/route.ts b/apps/sim/app/api/organizations/[id]/transfer-ownership/route.ts index 32385b2421f..13f61580a3f 100644 --- a/apps/sim/app/api/organizations/[id]/transfer-ownership/route.ts +++ b/apps/sim/app/api/organizations/[id]/transfer-ownership/route.ts @@ -148,6 +148,7 @@ export const POST = withRouteHandler( userId: session.user.id, organizationId, memberId: selfMember.id, + spareSessionToken: session.session.token, }) if (!removeResult.success) { diff --git a/apps/sim/app/api/scim/v2/Groups/[id]/route.ts b/apps/sim/app/api/scim/v2/Groups/[id]/route.ts new file mode 100644 index 00000000000..fc1343e359f --- /dev/null +++ b/apps/sim/app/api/scim/v2/Groups/[id]/route.ts @@ -0,0 +1,53 @@ +import { + deleteScimGroupContract, + getScimGroupContract, + patchScimGroupContract, + replaceScimGroupContract, +} from '@/lib/api/contracts/scim' +import { + deleteScimGroup, + getScimGroup, + patchScimGroup, + replaceScimGroup, +} from '@/ee/scim/lib/application/groups/manage-groups' +import { toCanonicalGroup } from '@/ee/scim/lib/protocol/canonical' +import { parseAttributeProjection } from '@/ee/scim/lib/protocol/resources' +import { defineScimRoute } from '@/ee/scim/lib/route' + +/** One Group resource. */ + +export const GET = defineScimRoute({ + contract: getScimGroupContract, + operation: getScimGroup.operation, + useCase: getScimGroup, + mapInput: ({ params, query }) => ({ + groupId: params.id, + projection: parseAttributeProjection(query), + }), + present: (resource) => resource, +}) + +export const PUT = defineScimRoute({ + contract: replaceScimGroupContract, + operation: replaceScimGroup.operation, + useCase: replaceScimGroup, + mapInput: ({ params, body }) => { + return { groupId: params.id, group: toCanonicalGroup(body) } + }, + present: (result) => result.resource, +}) + +/** Answers 204: Microsoft asks that a group patch not echo the member list. */ +export const PATCH = defineScimRoute({ + contract: patchScimGroupContract, + operation: patchScimGroup.operation, + useCase: patchScimGroup, + mapInput: ({ params, body }) => ({ groupId: params.id, operations: body.Operations }), +}) + +export const DELETE = defineScimRoute({ + contract: deleteScimGroupContract, + operation: deleteScimGroup.operation, + useCase: deleteScimGroup, + mapInput: ({ params }) => ({ groupId: params.id }), +}) diff --git a/apps/sim/app/api/scim/v2/Groups/route.ts b/apps/sim/app/api/scim/v2/Groups/route.ts new file mode 100644 index 00000000000..1bfa92d5a46 --- /dev/null +++ b/apps/sim/app/api/scim/v2/Groups/route.ts @@ -0,0 +1,31 @@ +import { createScimGroupContract, listScimGroupsContract } from '@/lib/api/contracts/scim' +import { createScimGroup, listScimGroups } from '@/ee/scim/lib/application/groups/manage-groups' +import { toCanonicalGroup } from '@/ee/scim/lib/protocol/canonical' +import { parseAttributeProjection, toListResponse } from '@/ee/scim/lib/protocol/resources' +import { defineScimRoute } from '@/ee/scim/lib/route' + +/** The Group collection. */ + +export const GET = defineScimRoute({ + contract: listScimGroupsContract, + operation: listScimGroups.operation, + useCase: listScimGroups, + mapInput: ({ query }) => ({ + filter: query.filter, + startIndex: query.startIndex, + count: query.count, + projection: parseAttributeProjection(query), + }), + present: (result) => toListResponse(result.resources, result.totalResults, result.startIndex), +}) + +export const POST = defineScimRoute({ + contract: createScimGroupContract, + operation: createScimGroup.operation, + useCase: createScimGroup, + mapInput: ({ body }) => { + return { group: toCanonicalGroup(body) } + }, + present: (result) => result.resource, + headers: (result, { baseUrl }) => ({ Location: `${baseUrl}/Groups/${result.groupId}` }), +}) diff --git a/apps/sim/app/api/scim/v2/ResourceTypes/[id]/route.ts b/apps/sim/app/api/scim/v2/ResourceTypes/[id]/route.ts new file mode 100644 index 00000000000..d4016ff07ec --- /dev/null +++ b/apps/sim/app/api/scim/v2/ResourceTypes/[id]/route.ts @@ -0,0 +1,12 @@ +import { defineScimDiscoveryRoute } from '@/lib/api/server/routes' +import { resourceTypes } from '@/ee/scim/lib/protocol/discovery' +import { notFound } from '@/ee/scim/lib/protocol/errors' + +export const GET = defineScimDiscoveryRoute((baseUrl, params) => { + const id = typeof params.id === 'string' ? params.id : '' + const match = resourceTypes(baseUrl).find( + (resource) => resource.id.toLowerCase() === id.toLowerCase() + ) + if (!match) throw notFound(`Resource type ${id} not found`) + return match +}) diff --git a/apps/sim/app/api/scim/v2/ResourceTypes/route.ts b/apps/sim/app/api/scim/v2/ResourceTypes/route.ts new file mode 100644 index 00000000000..6050e6ce2b7 --- /dev/null +++ b/apps/sim/app/api/scim/v2/ResourceTypes/route.ts @@ -0,0 +1,7 @@ +import { defineScimDiscoveryRoute } from '@/lib/api/server/routes' +import { resourceTypes } from '@/ee/scim/lib/protocol/discovery' +import { toListResponse } from '@/ee/scim/lib/protocol/resources' + +export const GET = defineScimDiscoveryRoute((baseUrl) => + toListResponse(resourceTypes(baseUrl), resourceTypes(baseUrl).length, 1) +) diff --git a/apps/sim/app/api/scim/v2/Schemas/[id]/route.ts b/apps/sim/app/api/scim/v2/Schemas/[id]/route.ts new file mode 100644 index 00000000000..c0c5010c01c --- /dev/null +++ b/apps/sim/app/api/scim/v2/Schemas/[id]/route.ts @@ -0,0 +1,12 @@ +import { defineScimDiscoveryRoute } from '@/lib/api/server/routes' +import { schemaDefinitions } from '@/ee/scim/lib/protocol/discovery' +import { notFound } from '@/ee/scim/lib/protocol/errors' + +export const GET = defineScimDiscoveryRoute((baseUrl, params) => { + const id = typeof params.id === 'string' ? decodeURIComponent(params.id) : '' + const match = schemaDefinitions(baseUrl).find( + (schema) => schema.id.toLowerCase() === id.toLowerCase() + ) + if (!match) throw notFound(`Schema ${id} not found`) + return match +}) diff --git a/apps/sim/app/api/scim/v2/Schemas/route.ts b/apps/sim/app/api/scim/v2/Schemas/route.ts new file mode 100644 index 00000000000..3202e1555e0 --- /dev/null +++ b/apps/sim/app/api/scim/v2/Schemas/route.ts @@ -0,0 +1,7 @@ +import { defineScimDiscoveryRoute } from '@/lib/api/server/routes' +import { schemaDefinitions } from '@/ee/scim/lib/protocol/discovery' +import { toListResponse } from '@/ee/scim/lib/protocol/resources' + +export const GET = defineScimDiscoveryRoute((baseUrl) => + toListResponse(schemaDefinitions(baseUrl), schemaDefinitions(baseUrl).length, 1) +) diff --git a/apps/sim/app/api/scim/v2/ServiceProviderConfig/route.ts b/apps/sim/app/api/scim/v2/ServiceProviderConfig/route.ts new file mode 100644 index 00000000000..05b68cd122c --- /dev/null +++ b/apps/sim/app/api/scim/v2/ServiceProviderConfig/route.ts @@ -0,0 +1,5 @@ +import { defineScimDiscoveryRoute } from '@/lib/api/server/routes' +import { serviceProviderConfig } from '@/ee/scim/lib/protocol/discovery' + +/** Unauthenticated by design: a provider negotiates before it holds a credential. */ +export const GET = defineScimDiscoveryRoute((baseUrl) => serviceProviderConfig(baseUrl)) diff --git a/apps/sim/app/api/scim/v2/Users/[id]/route.ts b/apps/sim/app/api/scim/v2/Users/[id]/route.ts new file mode 100644 index 00000000000..b3da9f3de24 --- /dev/null +++ b/apps/sim/app/api/scim/v2/Users/[id]/route.ts @@ -0,0 +1,50 @@ +import { + deleteScimUserContract, + getScimUserContract, + patchScimUserContract, + replaceScimUserContract, +} from '@/lib/api/contracts/scim' +import { deprovisionScimUser } from '@/ee/scim/lib/application/users/deprovision-user' +import { getScimUser } from '@/ee/scim/lib/application/users/read-users' +import { patchScimUser, replaceScimUser } from '@/ee/scim/lib/application/users/update-user' +import { toCanonicalUser } from '@/ee/scim/lib/protocol/canonical' +import { parseAttributeProjection } from '@/ee/scim/lib/protocol/resources' +import { defineScimRoute } from '@/ee/scim/lib/route' + +/** One User resource. */ + +export const GET = defineScimRoute({ + contract: getScimUserContract, + operation: getScimUser.operation, + useCase: getScimUser, + mapInput: ({ params, query }) => ({ + scimUserId: params.id, + projection: parseAttributeProjection(query), + }), + present: (resource) => resource, +}) + +export const PUT = defineScimRoute({ + contract: replaceScimUserContract, + operation: replaceScimUser.operation, + useCase: replaceScimUser, + mapInput: ({ params, body }) => { + return { scimUserId: params.id, attributes: toCanonicalUser(body) } + }, + present: (result) => result.resource, +}) + +export const PATCH = defineScimRoute({ + contract: patchScimUserContract, + operation: patchScimUser.operation, + useCase: patchScimUser, + mapInput: ({ params, body }) => ({ scimUserId: params.id, operations: body.Operations }), + present: (result) => result.resource, +}) + +export const DELETE = defineScimRoute({ + contract: deleteScimUserContract, + operation: deprovisionScimUser.operation, + useCase: deprovisionScimUser, + mapInput: ({ params }) => ({ scimUserId: params.id }), +}) diff --git a/apps/sim/app/api/scim/v2/Users/route.ts b/apps/sim/app/api/scim/v2/Users/route.ts new file mode 100644 index 00000000000..93b5338d1d6 --- /dev/null +++ b/apps/sim/app/api/scim/v2/Users/route.ts @@ -0,0 +1,38 @@ +import { createScimUserContract, listScimUsersContract } from '@/lib/api/contracts/scim' +import { provisionScimUser } from '@/ee/scim/lib/application/users/provision-user' +import { listScimUsers } from '@/ee/scim/lib/application/users/read-users' +import { toCanonicalUser } from '@/ee/scim/lib/protocol/canonical' +import { parseAttributeProjection, toListResponse } from '@/ee/scim/lib/protocol/resources' +import { defineScimRoute } from '@/ee/scim/lib/route' + +/** + * The User collection. + * + * Adapters only: authentication, rate policy, contract parsing, and rendering + * live in the route builder, and every decision about identity, membership, and + * access lives in `ee/scim/lib/application`. + */ + +export const GET = defineScimRoute({ + contract: listScimUsersContract, + operation: listScimUsers.operation, + useCase: listScimUsers, + mapInput: ({ query }) => ({ + filter: query.filter, + startIndex: query.startIndex, + count: query.count, + projection: parseAttributeProjection(query), + }), + present: (result) => toListResponse(result.resources, result.totalResults, result.startIndex), +}) + +export const POST = defineScimRoute({ + contract: createScimUserContract, + operation: provisionScimUser.operation, + useCase: provisionScimUser, + mapInput: ({ body }) => { + return { attributes: toCanonicalUser(body) } + }, + present: (result) => result.resource, + headers: (result, { baseUrl }) => ({ Location: `${baseUrl}/Users/${result.scimUserId}` }), +}) diff --git a/apps/sim/app/api/settings/allowed-integrations/route.test.ts b/apps/sim/app/api/settings/allowed-integrations/route.test.ts new file mode 100644 index 00000000000..6028ed121d7 --- /dev/null +++ b/apps/sim/app/api/settings/allowed-integrations/route.test.ts @@ -0,0 +1,78 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getSession: vi.fn(), + getIntegrationAvailability: vi.fn(), + getOAuthServiceAvailability: vi.fn(), + getAllOAuthServices: vi.fn(), +})) +vi.mock('@/lib/auth', () => ({ getSession: mocks.getSession })) +vi.mock('@/lib/core/config/env-flags', () => ({ getAllowedIntegrationsFromEnv: () => null })) +vi.mock('@/lib/integrations/availability.server', () => ({ + getIntegrationAvailability: mocks.getIntegrationAvailability, + getOAuthServiceAvailability: mocks.getOAuthServiceAvailability, +})) +vi.mock('@/lib/oauth/utils', () => ({ getAllOAuthServices: mocks.getAllOAuthServices })) + +import { getAllowedIntegrationsContract } from '@/lib/api/contracts/common' +import { GET } from '@/app/api/settings/allowed-integrations/route' + +describe('allowed integrations response', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getSession.mockResolvedValue({ user: { id: 'user-1' } }) + mocks.getIntegrationAvailability.mockReturnValue([ + { type: 'github_v2', state: 'ready', oauthAvailable: false, missingFields: [] }, + ]) + mocks.getAllOAuthServices.mockReturnValue([ + { providerId: 'github-repositories', authType: 'oauth' }, + ]) + mocks.getOAuthServiceAvailability.mockReturnValue([ + { providerId: 'github-repositories', available: false }, + ]) + }) + + it('authenticates before projecting deployment capabilities', async () => { + mocks.getSession.mockResolvedValue(null) + const response = await GET( + createMockRequest( + 'GET', + undefined, + undefined, + 'http://localhost/api/settings/allowed-integrations' + ), + {} + ) + expect(response.status).toBe(401) + expect(mocks.getIntegrationAvailability).not.toHaveBeenCalled() + expect(mocks.getOAuthServiceAvailability).not.toHaveBeenCalled() + expect(mocks.getAllOAuthServices).not.toHaveBeenCalled() + }) + + it('returns block and OAuth service readiness as distinct contract fields', async () => { + const response = await GET( + createMockRequest( + 'GET', + undefined, + undefined, + 'http://localhost/api/settings/allowed-integrations' + ), + {} + ) + expect(response.status).toBe(200) + const body = await response.json() + expect(getAllowedIntegrationsContract.response.schema.safeParse(body).success).toBe(true) + expect(body).toEqual({ + allowedIntegrations: null, + integrationAvailability: [{ type: 'github_v2', state: 'ready', oauthAvailable: false }], + oauthServiceAvailability: [{ providerId: 'github-repositories', available: false }], + }) + expect(mocks.getOAuthServiceAvailability).toHaveBeenCalledWith( + mocks.getAllOAuthServices.mock.results[0].value + ) + }) +}) diff --git a/apps/sim/app/api/settings/allowed-integrations/route.ts b/apps/sim/app/api/settings/allowed-integrations/route.ts index c5acc2582ca..ba7d83a103b 100644 --- a/apps/sim/app/api/settings/allowed-integrations/route.ts +++ b/apps/sim/app/api/settings/allowed-integrations/route.ts @@ -2,7 +2,11 @@ import { NextResponse } from 'next/server' import { getSession } from '@/lib/auth' import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getIntegrationAvailability } from '@/lib/integrations/availability.server' +import { + getIntegrationAvailability, + getOAuthServiceAvailability, +} from '@/lib/integrations/availability.server' +import { getAllOAuthServices } from '@/lib/oauth/utils' export const GET = withRouteHandler(async () => { const session = await getSession() @@ -15,5 +19,6 @@ export const GET = withRouteHandler(async () => { integrationAvailability: getIntegrationAvailability().map( ({ type, state, oauthAvailable }) => ({ type, state, oauthAvailable }) ), + oauthServiceAvailability: getOAuthServiceAvailability(getAllOAuthServices()), }) }) diff --git a/apps/sim/app/api/users/me/authorized-apps/[clientId]/route.ts b/apps/sim/app/api/users/me/authorized-apps/[clientId]/route.ts new file mode 100644 index 00000000000..7f7752b05cb --- /dev/null +++ b/apps/sim/app/api/users/me/authorized-apps/[clientId]/route.ts @@ -0,0 +1,24 @@ +import { revokeAuthorizedAppContract } from '@/lib/api/contracts' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { revokeAuthorizedAppUseCase } from '@/lib/users/application/authorized-apps' +import { userAccountOperations } from '@/lib/users/application/operations' + +export const dynamic = 'force-dynamic' + +export const DELETE = defineInternalJsonRoute({ + contract: revokeAuthorizedAppContract, + auth: internalSessionAuth, + operation: userAccountOperations.revokeAuthorizedApp, + rateLimit: internalRateLimits.none({ + reason: 'Authenticated current-user settings mutation', + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ clientId: params.clientId }), + useCase: revokeAuthorizedAppUseCase, + present: () => ({ success: true as const }), +}) diff --git a/apps/sim/app/api/users/me/authorized-apps/route.ts b/apps/sim/app/api/users/me/authorized-apps/route.ts new file mode 100644 index 00000000000..21c39ccc131 --- /dev/null +++ b/apps/sim/app/api/users/me/authorized-apps/route.ts @@ -0,0 +1,31 @@ +import { listAuthorizedAppsContract } from '@/lib/api/contracts' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { listAuthorizedAppsUseCase } from '@/lib/users/application/authorized-apps' +import { userAccountOperations } from '@/lib/users/application/operations' + +export const dynamic = 'force-dynamic' + +export const GET = defineInternalJsonRoute({ + contract: listAuthorizedAppsContract, + auth: internalSessionAuth, + operation: userAccountOperations.readAuthorizedApps, + rateLimit: internalRateLimits.none({ + reason: 'Authenticated current-user settings read', + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ query }) => query, + useCase: listAuthorizedAppsUseCase, + present: ({ apps, nextCursor }) => ({ + apps: apps.map((app) => ({ + ...app, + name: app.name?.trim() || app.clientId, + authorizedAt: app.authorizedAt.toISOString(), + })), + nextCursor, + }), +}) diff --git a/apps/sim/app/api/users/me/organization-accounts/[credentialId]/reconnect/route.ts b/apps/sim/app/api/users/me/organization-accounts/[credentialId]/reconnect/route.ts new file mode 100644 index 00000000000..c9d66c778e7 --- /dev/null +++ b/apps/sim/app/api/users/me/organization-accounts/[credentialId]/reconnect/route.ts @@ -0,0 +1,18 @@ +import { reconnectPersonalOrganizationAccountContract } from '@/lib/api/contracts/organization-accounts' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { reconnectPersonalOrganizationAccount } from '@/lib/credential-groups/application/personal-organization-accounts' + +export const POST = defineInternalJsonRoute({ + contract: reconnectPersonalOrganizationAccountContract, + auth: internalSessionAuth, + operation: reconnectPersonalOrganizationAccount.operation, + rateLimit: internalRateLimits.none({ reason: 'Current-user connected account management' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => params, + useCase: reconnectPersonalOrganizationAccount, +}) diff --git a/apps/sim/app/api/users/me/organization-accounts/[credentialId]/route.ts b/apps/sim/app/api/users/me/organization-accounts/[credentialId]/route.ts new file mode 100644 index 00000000000..56076c3e7ed --- /dev/null +++ b/apps/sim/app/api/users/me/organization-accounts/[credentialId]/route.ts @@ -0,0 +1,19 @@ +import { disconnectPersonalOrganizationAccountContract } from '@/lib/api/contracts/organization-accounts' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { disconnectPersonalOrganizationAccount } from '@/lib/credential-groups/application/personal-organization-accounts' + +export const DELETE = defineInternalJsonRoute({ + contract: disconnectPersonalOrganizationAccountContract, + auth: internalSessionAuth, + operation: disconnectPersonalOrganizationAccount.operation, + rateLimit: internalRateLimits.none({ reason: 'Current-user connected account management' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => params, + useCase: disconnectPersonalOrganizationAccount, + present: () => ({ success: true as const }), +}) diff --git a/apps/sim/app/api/users/me/organization-accounts/route.ts b/apps/sim/app/api/users/me/organization-accounts/route.ts new file mode 100644 index 00000000000..64d1129abeb --- /dev/null +++ b/apps/sim/app/api/users/me/organization-accounts/route.ts @@ -0,0 +1,18 @@ +import { listPersonalOrganizationAccountsContract } from '@/lib/api/contracts/organization-accounts' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { listPersonalOrganizationAccounts } from '@/lib/credential-groups/application/personal-organization-accounts' + +export const GET = defineInternalJsonRoute({ + contract: listPersonalOrganizationAccountsContract, + auth: internalSessionAuth, + operation: listPersonalOrganizationAccounts.operation, + rateLimit: internalRateLimits.none({ reason: 'Current-user connected account management' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ query }) => query, + useCase: listPersonalOrganizationAccounts, +}) diff --git a/apps/sim/app/api/users/me/settings/route.test.ts b/apps/sim/app/api/users/me/settings/route.test.ts index 03af01cd235..aafd95083ce 100644 --- a/apps/sim/app/api/users/me/settings/route.test.ts +++ b/apps/sim/app/api/users/me/settings/route.test.ts @@ -29,6 +29,15 @@ describe('PATCH /api/users/me/settings', () => { expect(await response.json()).toEqual({ success: true }) }) + it('does not acknowledge a privacy update when the session has expired', async () => { + mockGetSession.mockResolvedValue(null) + + const response = await PATCH(createMockRequest('PATCH', { telemetryEnabled: false })) + + expect(response.status).toBe(401) + expect(dbChainMockFns.insert).not.toHaveBeenCalled() + }) + /** * The regression this guards: the catch answered `{ success: true }` with 200, so * `useUpdateGeneralSetting`'s optimistic rollback in `onError` could never run — @@ -58,8 +67,20 @@ describe('GET /api/users/me/settings', () => { expect(response.status).toBe(200) await expect(response.json()).resolves.toMatchObject({ - data: { theme: 'system', autoConnect: true }, + data: { theme: 'system', autoConnect: true, telemetryEnabled: false }, }) expect(dbChainMockFns.select).not.toHaveBeenCalled() }) + + it('does not replace unavailable saved preferences with permission to collect', async () => { + mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) + dbChainMockFns.select.mockImplementationOnce(() => { + throw new Error('Database unavailable') + }) + + const response = await GET() + + expect(response.status).toBe(500) + await expect(response.json()).resolves.toEqual({ error: 'Failed to load settings' }) + }) }) diff --git a/apps/sim/app/api/users/me/settings/route.ts b/apps/sim/app/api/users/me/settings/route.ts index 3bafb97aaf4..7134a945123 100644 --- a/apps/sim/app/api/users/me/settings/route.ts +++ b/apps/sim/app/api/users/me/settings/route.ts @@ -7,7 +7,6 @@ import { updateUserSettingsContract } from '@/lib/api/contracts' import { parseRequest, validationErrorResponse } from '@/lib/api/server' import { InternalUnauthenticatedError, internalSessionAuth } from '@/lib/api/server/routes' import { getSession } from '@/lib/auth' -import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getCurrentUserSettingsUseCase } from '@/lib/users/application/read-current-user' import { defaultUserSettings } from '@/lib/users/queries' @@ -15,32 +14,25 @@ import { defaultUserSettings } from '@/lib/users/queries' const logger = createLogger('UserSettingsAPI') export const GET = withRouteHandler(async () => { - const requestId = generateRequestId() - try { const principal = await internalSessionAuth.authenticate() const data = await getCurrentUserSettingsUseCase.execute({ principal, input: {} }) return NextResponse.json({ data }, { status: 200 }) - } catch (error: any) { + } catch (error) { if (error instanceof InternalUnauthenticatedError) { - return NextResponse.json({ data: defaultUserSettings }, { status: 200 }) + return NextResponse.json({ data: { ...defaultUserSettings, telemetryEnabled: false } }) } - logger.error(`[${requestId}] Settings fetch error`, error) - return NextResponse.json({ data: defaultUserSettings }, { status: 200 }) + logger.error('Settings fetch error', error) + return NextResponse.json({ error: 'Failed to load settings' }, { status: 500 }) } }) export const PATCH = withRouteHandler(async (request: NextRequest) => { - const requestId = generateRequestId() - try { const session = await getSession() if (!session?.user?.id) { - logger.info( - `[${requestId}] Settings update attempted by unauthenticated user - acknowledged without saving` - ) - return NextResponse.json({ success: true }, { status: 200 }) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } const userId = session.user.id @@ -51,7 +43,7 @@ export const PATCH = withRouteHandler(async (request: NextRequest) => { {}, { validationErrorResponse: (error) => { - logger.warn(`[${requestId}] Invalid settings data`, { errors: error.issues }) + logger.warn('Invalid settings data', { errors: error.issues }) return validationErrorResponse(error, 'Invalid settings data') }, } @@ -77,12 +69,9 @@ export const PATCH = withRouteHandler(async (request: NextRequest) => { }) return NextResponse.json({ success: true }, { status: 200 }) - } catch (error: any) { - logger.error(`[${requestId}] Settings update error`, error) - /* The client mutation is optimistic: it writes the new value into the cache in - `onMutate` and restores it in `onError`. Answering 200 here left that rollback - unreachable, so a failed write showed as applied until the next refetch — - including for consent-shaped settings the user believes they changed. */ + } catch (error) { + logger.error('Settings update error', error) + /** Failed writes must trigger the client's optimistic rollback, including privacy choices. */ return NextResponse.json({ error: 'Failed to update settings' }, { status: 500 }) } }) diff --git a/apps/sim/app/api/v1/admin/dashboard/actor.ts b/apps/sim/app/api/v1/admin/dashboard/actor.ts index c3237cd200a..57b0cb0d912 100644 --- a/apps/sim/app/api/v1/admin/dashboard/actor.ts +++ b/apps/sim/app/api/v1/admin/dashboard/actor.ts @@ -1,16 +1,18 @@ import { db } from '@sim/db' -import { user } from '@sim/db/schema' -import { eq, or } from 'drizzle-orm' +import { foldedEmail, user } from '@sim/db/schema' +import { normalizeEmail } from '@sim/utils/string' +import { eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import type { AdminMutationActor } from '@/lib/admin/dashboard' export async function getAdminAuditActor(request: NextRequest): Promise { - const email = request.headers.get('x-admin-email')?.trim().toLowerCase() + const rawEmail = request.headers.get('x-admin-email') + const email = rawEmail ? normalizeEmail(rawEmail) : '' if (!email) return { id: null, name: 'Admin API', email: null } const [admin] = await db .select({ id: user.id, name: user.name, email: user.email }) .from(user) - .where(or(eq(user.email, email), eq(user.normalizedEmail, email))) + .where(eq(foldedEmail(user.email), email)) .limit(1) return admin ?? { id: null, name: 'Admin Panel', email } } diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/route.test.ts b/apps/sim/app/api/v1/admin/organizations/[id]/route.test.ts index 216d88bbd71..eefad3d2827 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/route.test.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/route.test.ts @@ -1,21 +1,34 @@ /** * @vitest-environment node */ -import { createMockRequest, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { recordAudit, recordAuditBatch } from '@sim/audit' +import { + createMockRequest, + dbChainMockFns, + queueTableRows, + resetDbChainMock, + schemaMock, +} from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockDetachOrganizationWorkspacesTx, mockDelete, mockAuthenticateAdminRequest } = vi.hoisted( - () => ({ - mockDetachOrganizationWorkspacesTx: vi.fn(), - mockDelete: vi.fn(), - mockAuthenticateAdminRequest: vi.fn(), - }) -) +const { + mockDetachOrganizationWorkspacesTx, + mockEnqueueResourceCleanup, + mockAuthenticateAdminRequest, +} = vi.hoisted(() => ({ + mockDetachOrganizationWorkspacesTx: vi.fn(), + mockEnqueueResourceCleanup: vi.fn(), + mockAuthenticateAdminRequest: vi.fn(), +})) vi.mock('@/lib/workspaces/organization-workspaces', () => ({ detachOrganizationWorkspacesTx: mockDetachOrganizationWorkspacesTx, })) +vi.mock('@/lib/organizations/resource-cleanup', () => ({ + enqueueOrganizationResourceCleanup: mockEnqueueResourceCleanup, +})) + vi.mock('@/app/api/v1/admin/auth', () => ({ authenticateAdminRequest: mockAuthenticateAdminRequest, })) @@ -61,7 +74,7 @@ describe('admin organization DELETE', () => { /** Returned rather than written, so the caller can emit them post-commit. */ auditEntries: [], }) - mockDelete.mockClear() + mockEnqueueResourceCleanup.mockResolvedValue(undefined) }) afterAll(resetDbChainMock) @@ -115,7 +128,7 @@ describe('admin organization DELETE', () => { expect(mockDetachOrganizationWorkspacesTx).toHaveBeenCalledWith(expect.anything(), ORG_ID) }) - it('detaches workspaces before deleting the organization', async () => { + it('detaches workspaces and enqueues resource cleanup before deleting in the same transaction', async () => { queueOrganization() queueTableRows(schemaMock.subscription, []) queueTableRows(schemaMock.member, [{ value: 3 }]) @@ -130,6 +143,15 @@ describe('admin organization DELETE', () => { * through so the detach and the delete commit together. */ expect(mockDetachOrganizationWorkspacesTx).toHaveBeenCalledWith(expect.anything(), ORG_ID) + const tx = mockDetachOrganizationWorkspacesTx.mock.calls[0][0] + expect(mockEnqueueResourceCleanup).toHaveBeenCalledExactlyOnceWith(tx, ORG_ID) + expect(mockDetachOrganizationWorkspacesTx.mock.invocationCallOrder[0]).toBeLessThan( + mockEnqueueResourceCleanup.mock.invocationCallOrder[0] + ) + expect(mockEnqueueResourceCleanup.mock.invocationCallOrder[0]).toBeLessThan( + dbChainMockFns.delete.mock.invocationCallOrder[0] + ) + expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(1) const body = await response.json() expect(body.data).toMatchObject({ @@ -140,4 +162,39 @@ describe('admin organization DELETE', () => { workspacesDetached: 2, }) }) + + it('aborts the transaction before cascade and audit when durable cleanup cannot be queued', async () => { + queueOrganization() + queueTableRows(schemaMock.subscription, []) + queueTableRows(schemaMock.member, [{ value: 3 }]) + mockEnqueueResourceCleanup.mockRejectedValueOnce(new Error('outbox unavailable')) + + const response = await DELETE(deleteRequest('acme-inc'), routeContext) + + expect(response.status).toBe(500) + expect(mockDetachOrganizationWorkspacesTx).toHaveBeenCalledTimes(1) + expect(mockEnqueueResourceCleanup).toHaveBeenCalledExactlyOnceWith( + mockDetachOrganizationWorkspacesTx.mock.calls[0][0], + ORG_ID + ) + expect(dbChainMockFns.delete).not.toHaveBeenCalled() + expect(recordAudit).not.toHaveBeenCalled() + expect(recordAuditBatch).not.toHaveBeenCalled() + }) + + it('does not emit success audit after a cascade failure', async () => { + queueOrganization() + queueTableRows(schemaMock.subscription, []) + queueTableRows(schemaMock.member, [{ value: 3 }]) + dbChainMockFns.delete.mockImplementationOnce(() => { + throw new Error('cascade unavailable') + }) + + const response = await DELETE(deleteRequest('acme-inc'), routeContext) + + expect(response.status).toBe(500) + expect(mockEnqueueResourceCleanup).toHaveBeenCalledTimes(1) + expect(recordAudit).not.toHaveBeenCalled() + expect(recordAuditBatch).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/app/api/v1/admin/organizations/[id]/route.ts b/apps/sim/app/api/v1/admin/organizations/[id]/route.ts index 9849dc47761..15ca0aca873 100644 --- a/apps/sim/app/api/v1/admin/organizations/[id]/route.ts +++ b/apps/sim/app/api/v1/admin/organizations/[id]/route.ts @@ -58,6 +58,7 @@ import { TERMINAL_SUBSCRIPTION_STATUSES, } from '@/lib/billing/subscriptions/utils' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { enqueueOrganizationResourceCleanup } from '@/lib/organizations/resource-cleanup' import { detachOrganizationWorkspacesTx } from '@/lib/workspaces/organization-workspaces' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { @@ -299,6 +300,7 @@ export const DELETE = withRouteHandler( */ const { detachedWorkspaceIds, auditEntries } = await db.transaction(async (tx) => { const detached = await detachOrganizationWorkspacesTx(tx, organizationId) + await enqueueOrganizationResourceCleanup(tx, organizationId) await tx.delete(organization).where(eq(organization.id, organizationId)) return detached }) diff --git a/apps/sim/app/api/v1/knowledge/[id]/documents/route.test.ts b/apps/sim/app/api/v1/knowledge/[id]/documents/route.test.ts index d63e5f2ae4f..56d10d72095 100644 --- a/apps/sim/app/api/v1/knowledge/[id]/documents/route.test.ts +++ b/apps/sim/app/api/v1/knowledge/[id]/documents/route.test.ts @@ -75,8 +75,8 @@ vi.mock('@/lib/uploads/contexts/workspace', () => ({ vi.mock('@/lib/uploads/utils/validation', () => ({ validateFileType: mockValidateFileType, - // Read at module scope by `lib/uploads/utils/file-utils`, which the route now - // reaches transitively through the knowledge orchestration module. + /** Shared upload/connector limits are read by the knowledge orchestration imports. */ + MAX_FILE_SIZE: 100 * 1024 * 1024, SUPPORTED_ARCHIVE_EXTENSIONS: [], })) diff --git a/apps/sim/app/api/v1/knowledge/utils.ts b/apps/sim/app/api/v1/knowledge/utils.ts index 95655cbe756..8d721d0ec4a 100644 --- a/apps/sim/app/api/v1/knowledge/utils.ts +++ b/apps/sim/app/api/v1/knowledge/utils.ts @@ -58,7 +58,7 @@ export async function resolveKnowledgeBase( */ export async function resolveV1KnowledgeAccessScope( userId: string, - rateLimit: { keyType?: 'personal' | 'workspace' }, + rateLimit: { keyType?: 'personal' | 'workspace' | 'oauth_access_token' }, workspaceId: string | undefined ): Promise { if (rateLimit.keyType === 'workspace') return WORKSPACE_ACCESS_SCOPE diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index dbde4f2837a..d390a2403f9 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -73,7 +73,12 @@ export interface RateLimitResult { retryAfterMs?: number userId?: string workspaceId?: string - keyType?: 'personal' | 'workspace' + /** + * `oauth_access_token` never arises on v1, which authenticates API keys only; + * it is here because the v2 builders record their rate-limit snapshot in this + * shape. + */ + keyType?: 'personal' | 'workspace' | 'oauth_access_token' principal?: PersonalApiKeyPrincipal | WorkspaceApiKeyPrincipal error?: string } diff --git a/apps/sim/app/api/v2/chat-deployments/route.ts b/apps/sim/app/api/v2/chat-deployments/route.ts index b6e20424d1e..31e5c69de80 100644 --- a/apps/sim/app/api/v2/chat-deployments/route.ts +++ b/apps/sim/app/api/v2/chat-deployments/route.ts @@ -1,7 +1,8 @@ import { v2ListChatDeploymentsContract } from '@/lib/api/contracts/v2/chat-deployments' import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding' import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' -import { chatDeploymentOperations, listChatDeployments } from '@/lib/chat-deployments/application' +import { listChatDeployments } from '@/lib/chat-deployments/application' +import { chatDeploymentOperations } from '@/lib/chat-deployments/application/operations' import { chatDeploymentWorkspaceErrorPolicy, toV2ChatDeploymentListItem, diff --git a/apps/sim/app/api/v2/chat/route.test.ts b/apps/sim/app/api/v2/chat/route.test.ts index af7f28a3930..0e724a2d61d 100644 --- a/apps/sim/app/api/v2/chat/route.test.ts +++ b/apps/sim/app/api/v2/chat/route.test.ts @@ -273,7 +273,7 @@ describe('POST /api/v2/chat', () => { it('rejects a missing or invalid API key', async () => { mockAuthenticateV2ApiKey.mockRejectedValue( - new MockV2ApiKeyUnauthenticatedError('API key required') + new MockV2ApiKeyUnauthenticatedError('API key or OAuth access token required') ) const response = await callChat({ workspaceId: 'workspace-1', message: 'hi' }) @@ -337,8 +337,8 @@ describe('POST /api/v2/chat', () => { }) /** - * The route only ever runs for a personal API key, and `admitV2Request` never - * authorizes, so both halves of the funnel's personal-key policy have to be + * The route only runs for a user-held credential, and `admitV2Request` never + * authorizes, so both halves of the funnel's credential policy have to be * repeated here. The workspace column is the first half. */ it('answers 403 when the workspace has switched personal API keys off', async () => { diff --git a/apps/sim/app/api/v2/chat/route.ts b/apps/sim/app/api/v2/chat/route.ts index 17932605f92..2ea89aebe20 100644 --- a/apps/sim/app/api/v2/chat/route.ts +++ b/apps/sim/app/api/v2/chat/route.ts @@ -1,3 +1,8 @@ +import { + isUserCredentialPrincipal, + type OAuthAccessTokenPrincipal, + type PersonalApiKeyPrincipal, +} from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' @@ -37,12 +42,14 @@ import { requestExplicitStreamAbort } from '@/lib/copilot/request/session/explic import type { OrchestratorResult, StreamEvent } from '@/lib/copilot/request/types' import { normalizeSecretMountPolicy } from '@/lib/copilot/secret-mount-policy' import { + ForbiddenOperationError, forbiddenErrorDetails, PersonalApiKeysDisabledError, - requirePersonalApiKeysAllowed, + requireUserCredentialCapabilities, type WorkspaceAuthorizationContext, } from '@/lib/core/application' import { isDocSandboxEnabled } from '@/lib/core/config/env-flags' +import { acceptsMediaType } from '@/lib/core/utils/media-types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils' import { CAPABILITY_RULES } from '@/lib/permission-groups/capabilities' @@ -87,8 +94,9 @@ function deriveConversationTitle(message: string): string | undefined { } /** - * The two personal-API-key checks `authorizeWorkspaceOperation` applies, for the - * one route that never reaches it, or `null` when the key may proceed. + * The two user-credential checks `authorizeWorkspaceOperation` applies, for + * the one route that never reaches it, or `null` when the credential may + * proceed. * * The group half runs through the same {@link requirePersonalApiKeysAllowed} the * funnel and the billing reads call, so a third wording of the same refusal @@ -96,19 +104,19 @@ function deriveConversationTitle(message: string): string | undefined { * renders its own v2 envelope, and the detail code is read off the error so the * column refusal and the group refusal answer with one code. */ -async function personalApiKeyPolicyRefusal( - userId: string, +async function userCredentialPolicyRefusal( + principal: PersonalApiKeyPrincipal | OAuthAccessTokenPrincipal, context: WorkspaceAuthorizationContext ): Promise { - const refuse = (error: PersonalApiKeysDisabledError) => + const refuse = (error: ForbiddenOperationError) => v2Error('FORBIDDEN', error.message, { details: forbiddenErrorDetails(error) }) if (!context.allowPersonalApiKeys) return refuse(new PersonalApiKeysDisabledError()) try { - await requirePersonalApiKeysAllowed(userId, context) + await requireUserCredentialCapabilities(principal, context) } catch (error) { - if (error instanceof PersonalApiKeysDisabledError) return refuse(error) + if (error instanceof ForbiddenOperationError) return refuse(error) throw error } return null @@ -121,7 +129,7 @@ function isAbortError(error: unknown): boolean { function wantsStreamedChatResponse(req: NextRequest): boolean { return ( req.headers.get(CHAT_STREAM_HEADER) === CHAT_STREAM_VALUE || - req.headers.get('accept')?.includes(CHAT_STREAM_CONTENT_TYPE) === true + acceptsMediaType(req.headers.get('accept'), CHAT_STREAM_CONTENT_TYPE) ) } @@ -164,8 +172,8 @@ function buildChatResultPayload( * POST /api/v2/chat * * One conversational turn against the same headless execution path as the Sim - * Chat block (`/api/mothership/execute`), authenticated with a personal API key - * instead of the executor's internal JWT. JSON callers get one final response; + * Chat block (`/api/mothership/execute`), authenticated with an OAuth access + * token or personal API key instead of the executor's internal JWT. JSON callers get one final response; * NDJSON callers (`Accept: application/x-ndjson`) get heartbeats and incremental * `chunk` events followed by a `final` event, so long-running turns do not look * idle to intermediaries. @@ -185,8 +193,8 @@ export const POST = withRouteHandler( if (!admission.success) return admission.response const { principal } = admission.auth - if (principal.kind !== 'personal_api_key') { - return v2Error('FORBIDDEN', 'Chat requires a personal API key', { + if (!isUserCredentialPrincipal(principal)) { + return v2Error('FORBIDDEN', 'Chat requires a personal API key or an OAuth access token', { details: { code: 'PRINCIPAL_KIND_NOT_PERMITTED' }, }) } @@ -205,16 +213,17 @@ export const POST = withRouteHandler( const userPermission = workspaceAccess.permission /** - * permission-group-enforced: personal_api_key.use — this route only ever - * runs for a personal API key, and `admitV2Request` authenticates one - * without authorizing it, so the funnel's personal-key policy has to be - * repeated here or the same key `authorizeWorkspaceOperation` refuses - * still starts a chat turn. + * permission-group-enforced: personal_api_key.use — this route runs for a + * user-held credential, and `admitV2Request` authenticates one without + * authorizing it, so the funnel's user-credential policy has to be repeated + * here or the same principal `authorizeWorkspaceOperation` refuses still + * starts a chat turn. * * Both halves, because they combine with AND: the workspace column is the * coarse switch every workspace has, and the group key narrows it further * for one cohort inside an enterprise organization. Either one saying no - * is a no, and checking only `copilot.use` applied neither. + * is a no, and checking only `copilot.use` applied neither. A CLI token + * passes `cli.use` in the same call, for the same reason. * * Both run after workspace access rather than before it, unlike the * funnel, which can afford to check the column first because its caller @@ -222,12 +231,12 @@ export const POST = withRouteHandler( * it, and answering later only ever conceals more: a caller with no reach * into the workspace is refused without learning how it is configured. */ - const personalKeyRefusal = await personalApiKeyPolicyRefusal(userId, { + const credentialRefusal = await userCredentialPolicyRefusal(principal, { workspaceId, workspaceOrganizationId: workspaceAccess.workspace?.organizationId ?? null, allowPersonalApiKeys: workspaceAccess.workspace?.allowPersonalApiKeys ?? false, }) - if (personalKeyRefusal) return personalKeyRefusal + if (credentialRefusal) return credentialRefusal /** * permission-group-enforced: copilot.use — read off the operation so this @@ -326,7 +335,7 @@ export const POST = withRouteHandler( const [workspaceContext, integrationTools, entitlements, billingAttribution] = await Promise.all([ generateWorkspaceContext(workspaceId, userId, { workspaceAccess, secretMountPolicy }), - buildIntegrationToolSchemas(userId, messageId, undefined, workspaceId), + buildIntegrationToolSchemas(userId, undefined, workspaceId), computeWorkspaceEntitlements(workspaceId, userId), // Hosted execution refuses to run without an attribution snapshot; // the executor path receives it as a header, this path resolves it diff --git a/apps/sim/app/api/v2/files/[fileId]/share/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/share/route.test.ts index 68a57cab85b..f7023b562a6 100644 --- a/apps/sim/app/api/v2/files/[fileId]/share/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/share/route.test.ts @@ -104,7 +104,7 @@ describe('GET /api/v2/files/[fileId]/share', () => { it('authenticates and rate-limits before parsing or executing', async () => { v2RouteMocks.authenticate.mockRejectedValueOnce( - new MockV2ApiKeyUnauthenticatedError('API key required') + new MockV2ApiKeyUnauthenticatedError('API key or OAuth access token required') ) const response = await callGet() diff --git a/apps/sim/app/api/v2/files/bulk-download/route.ts b/apps/sim/app/api/v2/files/bulk-download/route.ts index 06c7a9096af..f2322d778ce 100644 --- a/apps/sim/app/api/v2/files/bulk-download/route.ts +++ b/apps/sim/app/api/v2/files/bulk-download/route.ts @@ -9,6 +9,7 @@ import { downloadFileStream } from '@/lib/uploads/core/storage-service' import { buildZipEntryPaths } from '@/lib/uploads/zip-entry-path' import { v2FileErrorPolicies } from '@/lib/workspace-files/api' import { downloadWorkspaceFileItems } from '@/lib/workspace-files/application/download-workspace-file-items' +import { fileOperations } from '@/lib/workspace-files/application/operations' const logger = createLogger('V2FilesBulkDownloadAPI') @@ -43,7 +44,7 @@ export const GET = defineV2BinaryRoute({ contract: v2BulkDownloadFilesContract, auth: v2ApiKeyAuth, headSafe: false, - operation: downloadWorkspaceFileItems.operation, + operation: fileOperations.download, rateLimit: v2RateLimits.publicApi, errorPolicy: v2FileErrorPolicies.default, mapInput: ({ query }) => ({ @@ -58,6 +59,7 @@ export const GET = defineV2BinaryRoute({ filesToZip.map((file) => ({ name: file.name, folderPath: file.folderId ? folderPaths.get(file.folderId) : null, + contentType: file.type, })) ) const archive = new ZipArchive({ store: true }) diff --git a/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks/route.test.ts b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks/route.test.ts index fe52256bca6..54187076a46 100644 --- a/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks/route.test.ts +++ b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunks/route.test.ts @@ -325,9 +325,10 @@ describe('PATCH /api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/chunk }) describe('chunk operation policy', () => { - it('denies workspace API keys on every chunk operation', () => { + it('allows ACL-filtered chunk listing while retaining the other chunk policies', () => { + expect(knowledgeOperations.listChunks.workspaceApiKey).toBe('allow') + expect(knowledgeOperations.listChunks.principalKinds).toContain('workspace_api_key') for (const operation of [ - knowledgeOperations.listChunks, knowledgeOperations.readChunk, knowledgeOperations.createChunk, knowledgeOperations.updateChunk, diff --git a/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/route.ts b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/route.ts index 1a9b32f4d7b..b798b626073 100644 --- a/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/route.ts +++ b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/[documentId]/route.ts @@ -1,3 +1,4 @@ +import { isUserCredentialPrincipal } from '@sim/auth/principal' import { V2_WRITABLE_TAG_SLOTS, type V2UpdateKnowledgeDocumentBody, @@ -136,7 +137,7 @@ export const DELETE = defineV2JsonRoute({ }), useCase: deleteKnowledgeDocument, onSuccess: ({ principal, input }) => { - if (principal.kind === 'personal_api_key') { + if (isUserCredentialPrincipal(principal)) { captureServerEvent( principal.userId, 'knowledge_base_document_deleted', diff --git a/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/route.ts b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/route.ts index 70d57103e8f..c2b141ef844 100644 --- a/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/route.ts +++ b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/route.ts @@ -1,3 +1,4 @@ +import { isUserCredentialPrincipal } from '@sim/auth/principal' import { omit } from '@sim/utils/object' import { parseV2KnowledgeTagFiltersParam, @@ -243,7 +244,7 @@ export const POST = defineV2BodyLifecycleRoute({ mimeType: result.document.mimeType, fileSize: result.document.fileSize, }) - if (principal.kind === 'personal_api_key') { + if (isUserCredentialPrincipal(principal)) { captureServerEvent( principal.userId, 'knowledge_base_document_uploaded', diff --git a/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/[uploadId]/complete/route.ts b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/[uploadId]/complete/route.ts index c95c245fcbf..65736ace100 100644 --- a/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/[uploadId]/complete/route.ts +++ b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/[uploadId]/complete/route.ts @@ -1,3 +1,4 @@ +import { isUserCredentialPrincipal } from '@sim/auth/principal' import { v2CompleteKnowledgeDocumentUploadContract } from '@/lib/api/contracts/v2/knowledge' import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' import { PlatformEvents } from '@/lib/core/telemetry' @@ -22,7 +23,7 @@ export const POST = defineV2JsonRoute({ }), useCase: completeKnowledgeDocumentUpload, onSuccess: ({ principal, result }) => { - if (result.value.created && principal.kind === 'personal_api_key') { + if (result.value.created && isUserCredentialPrincipal(principal)) { captureServerEvent( principal.userId, 'knowledge_base_document_uploaded', diff --git a/apps/sim/app/api/v2/knowledge/route.ts b/apps/sim/app/api/v2/knowledge/route.ts index bfa26e6ccdd..31fcbd08ccd 100644 --- a/apps/sim/app/api/v2/knowledge/route.ts +++ b/apps/sim/app/api/v2/knowledge/route.ts @@ -1,3 +1,4 @@ +import { isUserCredentialPrincipal } from '@sim/auth/principal' import { v2CreateKnowledgeBaseContract, v2ListKnowledgeBasesContract, @@ -105,7 +106,7 @@ export const POST = defineV2JsonRoute({ name: knowledgeBase.name, workspaceId: knowledgeBase.workspaceId ?? undefined, }) - if (principal.kind === 'personal_api_key') { + if (isUserCredentialPrincipal(principal)) { captureServerEvent( principal.userId, 'knowledge_base_created', diff --git a/apps/sim/app/api/v2/knowledge/search/route.provenance.test.ts b/apps/sim/app/api/v2/knowledge/search/route.provenance.test.ts new file mode 100644 index 00000000000..75f47e6f119 --- /dev/null +++ b/apps/sim/app/api/v2/knowledge/search/route.provenance.test.ts @@ -0,0 +1,355 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + resolveWorkspace: vi.fn(), + resolvePermission: vi.fn(), + getKnowledgeBase: vi.fn(), + resolveBilling: vi.fn(), + checkUsage: vi.fn(), + checkActorUsage: vi.fn(), + generateEmbedding: vi.fn(), + executeSearch: vi.fn(), + getDocumentMetadata: vi.fn(), + getTagDefinitions: vi.fn(), + recordEmbeddingUsage: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => { + const rank = { read: 1, write: 2, admin: 3 } as const + return ( + actual !== null && rank[actual as keyof typeof rank] >= rank[required as keyof typeof rank] + ) + }, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/billing/core/billing-attribution', () => ({ + resolveBillingAttribution: mocks.resolveBilling, + resolveSystemBillingAttribution: mocks.resolveBilling, + checkAttributedUsageLimits: mocks.checkUsage, +})) + +/** Retrieval defaults are the flag's concern; here the flag is off so the search stays as configured. */ +vi.mock('@/lib/knowledge/access/availability', () => ({ + isKnowledgeMemberAccessAvailable: async () => false, +})) + +vi.mock('@/lib/billing/calculations/usage-monitor', () => ({ + checkActorUsageLimits: mocks.checkActorUsage, +})) + +vi.mock('@/lib/knowledge/application/contexts', () => ({ + resolveKnowledgeWorkspaceContext: mocks.resolveWorkspace, +})) + +vi.mock('@/lib/knowledge/service', () => ({ + getKnowledgeBaseById: mocks.getKnowledgeBase, +})) + +vi.mock('@/lib/knowledge/embeddings', () => ({ + generateSearchEmbedding: mocks.generateEmbedding, + recordSearchEmbeddingUsage: mocks.recordEmbeddingUsage, +})) + +vi.mock('@/lib/knowledge/search/queries', () => ({ + generateSearchEmbedding: mocks.generateEmbedding, + executeKnowledgeSearch: mocks.executeSearch, + getDocumentMetadataByIds: mocks.getDocumentMetadata, +})) + +vi.mock('@/lib/knowledge/tags/service', () => ({ + getDocumentTagDefinitions: mocks.getTagDefinitions, +})) + +vi.mock('@/lib/knowledge/tags/utils', () => ({ + buildUndefinedTagsError: (tags: string[]) => `Undefined tags: ${tags.join(', ')}`, + validateTagValue: () => null, +})) + +import { searchKnowledge } from '@/lib/knowledge/application/search' + +const workspace = { + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +const knowledgeBase = { + id: 'knowledge-1', + userId: 'user-1', + name: 'Docs', + workspaceId: 'workspace-1', + embeddingModel: 'text-embedding-3-small', + embeddingDimension: 1536, +} + +import { document, embedding } from '@sim/db/schema' +import { sha256Hex } from '@sim/security/hash' +import { + queueTableRows, + resetDbChainMock, + V2_OPERATION_RATE_LIMIT_ALLOWED, + V2_PREAUTH_RATE_LIMIT_ALLOWED, + v2ApiKeyAuthModuleMock, + v2RateLimiterModuleMock, + v2RouteMocks, +} from '@sim/testing' +import { NextRequest } from 'next/server' +import { env } from '@/lib/core/config/env' +import { + isDurableSecretProvenanceEnforced, + resetDurableSecretProvenanceEnforcementCache, +} from '@/lib/execution/durable-secret-provenance-enforcement' +import { createKnowledgeDocumentSourceValue } from '@/lib/knowledge/secret-provenance' +import { POST } from '@/app/api/v2/knowledge/search/route' +import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' + +const provider = vi.hoisted(() => ({ fetch: vi.fn(), decrypt: vi.fn() })) +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => v2ApiKeyAuthModuleMock) +vi.mock('@/lib/core/rate-limiter', () => v2RateLimiterModuleMock) +vi.mock('@/lib/core/rate-limiter/storage/factory', () => ({ + createStorageAdapter: () => ({ + consumeTokensAtomically: async () => ({ allowed: true, retryAfterMs: 0 }), + getCooldownUntil: async () => null, + setCooldownUntil: async () => undefined, + }), +})) +vi.mock('@/lib/api-key/byok', () => ({ getBYOKKey: async () => null })) +vi.mock('@/lib/core/security/encryption', () => ({ decryptSecret: provider.decrypt })) + +const SECRET = 'synthetic-audit-secret-7b88a2' +const CONTENT = `Stored knowledge contains ${SECRET} in this synthetic fixture.` +const HASH = sha256Hex(CONTENT) +const PRINCIPAL = { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' } as const +const requestInput = { + workspaceId: 'workspace-1', + knowledgeBaseIds: ['knowledge-1'], + query: 'find the fixture', + topK: 1, + rerankerEnabled: true, + rerankerModel: 'rerank-v4.0-fast', +} +const source = createKnowledgeDocumentSourceValue({ + filename: 'synthetic.txt', + fileUrl: 'https://example.invalid/synthetic.txt', +}) +const row = { + id: 'embedding-1', + documentId: 'document-1', + knowledgeBaseId: 'knowledge-1', + content: CONTENT, + chunkIndex: 0, + distance: 0.2, + tag1: null, + tag2: null, + tag3: null, + tag4: null, + tag5: null, + tag6: null, + tag7: null, + number1: null, + number2: null, + number3: null, + number4: null, + number5: null, + date1: null, + date2: null, + boolean1: null, + boolean2: null, + boolean3: null, +} + +function seedSidecar(status: 'exact' | 'unknown' | 'legacy' | 'missing' | 'stale' | 'malformed') { + queueTableRows(embedding, [ + { + ...row, + secretProvenanceVersion: status === 'legacy' ? null : 1, + chunkHash: HASH, + provenanceContentHash: status === 'stale' ? 'old-hash' : HASH, + status: status === 'missing' ? null : status === 'unknown' ? 'unknown' : 'exact', + entries: + status === 'malformed' + ? [{ encryptedValue: 123 }] + : status === 'exact' + ? [ + { + name: 'TOKEN', + encryptedValue: 'synthetic-encrypted-token', + sourceUserId: 'user-1', + sourceWorkspaceId: 'workspace-1', + }, + ] + : [], + }, + ]) + queueTableRows(document, [ + { + id: 'document-1', + ...source, + secretProvenanceVersion: null, + provenanceSourceHash: null, + status: null, + entries: null, + }, + ]) +} + +function providerPayload() { + expect(provider.fetch).toHaveBeenCalledTimes(1) + expect(provider.fetch.mock.calls[0][0]).toBe('https://api.cohere.com/v2/rerank') + return JSON.parse(provider.fetch.mock.calls[0][1].body) +} + +function enforceKnowledge(enforced: boolean) { + env.DURABLE_SECRET_PROVENANCE_ENFORCED_SURFACES = enforced ? 'all' : '' + resetDurableSecretProvenanceEnforcementCache() + expect(isDurableSecretProvenanceEnforced('knowledge')).toBe(enforced) +} + +async function requestSearch(overrides: Partial = {}) { + return POST( + new NextRequest('http://localhost/api/v2/knowledge/search', { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-api-key': 'synthetic-key' }, + body: JSON.stringify({ ...requestInput, ...overrides }), + }) + ) +} + +beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + enforceKnowledge(true) + env.COHERE_API_KEY = 'synthetic-cohere-key' + provider.decrypt.mockResolvedValue({ decrypted: SECRET }) + provider.fetch.mockResolvedValue( + new Response(JSON.stringify({ results: [{ index: 0, relevance_score: 0.9 }] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + vi.stubGlobal('fetch', provider.fetch) + mocks.resolveWorkspace.mockResolvedValue(workspace) + mocks.resolvePermission.mockResolvedValue('read') + mocks.getKnowledgeBase.mockResolvedValue(knowledgeBase) + mocks.resolveBilling.mockResolvedValue({ actorUserId: 'user-1', workspaceId: 'workspace-1' }) + mocks.checkUsage.mockResolvedValue({ isExceeded: false }) + mocks.checkActorUsage.mockResolvedValue({ isExceeded: false }) + mocks.generateEmbedding.mockResolvedValue({ embedding: [0.1], isBYOK: false }) + mocks.executeSearch.mockResolvedValue([row]) + mocks.getDocumentMetadata.mockResolvedValue({ + 'document-1': { filename: 'synthetic.txt', sourceUrl: null }, + }) + mocks.getTagDefinitions.mockResolvedValue([]) + v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED) + v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED) + v2RouteMocks.authenticate.mockResolvedValue({ + principal: PRINCIPAL, + rateLimitSubjectIds: ['api-key:key-1', 'workspace:workspace-1'], + rateLimitSubscription: null, + keyType: 'personal', + }) +}) + +/** The route, use case, sidecar binding/import, registry, projection and provider request builder are real. */ +describe('Knowledge search provenance through the V2 route and reranker HTTP boundary', () => { + it.each([false, true])( + 'redacts current known-secret chunks with enforcement=%s', + async (enforced) => { + enforceKnowledge(enforced) + seedSidecar('exact') + const response = await requestSearch() + const body = await response.json() + expect(response.status).toBe(200) + expect(body.data.rerankerStatus).toBe('applied') + expect(providerPayload().documents).toEqual([CONTENT.replace(SECRET, '{{TOKEN}}')]) + expect(body.data.results[0].content).toBe(CONTENT) + expect(provider.decrypt).toHaveBeenCalledWith('synthetic-encrypted-token') + expect(mocks.generateEmbedding).toHaveBeenCalledWith( + requestInput.query, + expect.anything(), + 'workspace-1', + undefined + ) + } + ) + + it('does not assign a billing owner secret name to a workspace-key caller', async () => { + seedSidecar('exact') + v2RouteMocks.authenticate.mockResolvedValue({ + principal: { kind: 'workspace_api_key', workspaceId: 'workspace-1', keyId: 'key-1' }, + rateLimitSubjectIds: ['api-key:key-1', 'workspace:workspace-1'], + rateLimitSubscription: null, + keyType: 'workspace', + }) + const response = await requestSearch() + expect(response.status).toBe(200) + expect(providerPayload().documents).toEqual([CONTENT.replace(SECRET, '[REDACTED_SECRET]')]) + }) + + it('keeps a trusted incoming registry for existing internal and tool callers', async () => { + seedSidecar('exact') + const registry = new ResolvedSecretTraceRegistry([], { + userId: 'user-1', + workspaceId: 'workspace-1', + }) + const result = await searchKnowledge.execute({ + principal: PRINCIPAL, + input: { ...requestInput, resultSecretRegistry: registry }, + }) + expect(result.rerankerStatus).toBe('applied') + expect(result.resultSecretRegistry).toBe(registry) + expect(providerPayload().documents).toEqual([CONTENT.replace(SECRET, '{{TOKEN}}')]) + }) + + it.each(['unknown', 'missing', 'stale', 'malformed'] as const)( + 'refuses %s tracked provenance before provider HTTP when enforcement is enabled', + async (status) => { + seedSidecar(status) + const response = await requestSearch() + expect(response.status).toBe(409) + expect(await response.json()).toMatchObject({ + error: { code: 'CONFLICT', message: 'Knowledge result secret provenance is unavailable' }, + }) + expect(provider.fetch).not.toHaveBeenCalled() + } + ) + + it.each(['unknown', 'missing', 'stale', 'malformed'] as const)( + 'preserves existing flag-off compatibility for %s sidecars', + async (status) => { + enforceKnowledge(false) + seedSidecar(status) + const response = await requestSearch() + expect(response.status).toBe(200) + expect(providerPayload().documents).toEqual([CONTENT]) + } + ) + + it.each([false, true])( + 'keeps pre-tracking NULL rows readable with enforcement=%s', + async (enforced) => { + enforceKnowledge(enforced) + seedSidecar('legacy') + const response = await requestSearch() + expect(response.status).toBe(200) + expect(providerPayload().documents).toEqual([CONTENT]) + } + ) + + it('does not subject a raw public read without reranking to durable-model enforcement', async () => { + seedSidecar('unknown') + const response = await requestSearch({ rerankerEnabled: false }) + const body = await response.json() + expect(response.status).toBe(200) + expect(body.data.results[0].content).toBe(CONTENT) + expect(provider.fetch).not.toHaveBeenCalled() + expect(provider.decrypt).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/knowledge/search/route.test.ts b/apps/sim/app/api/v2/knowledge/search/route.test.ts index 562c995d3aa..1688466dcc6 100644 --- a/apps/sim/app/api/v2/knowledge/search/route.test.ts +++ b/apps/sim/app/api/v2/knowledge/search/route.test.ts @@ -87,6 +87,7 @@ describe('POST /api/v2/knowledge/search', () => { expect(mockSearch).toHaveBeenCalledWith({ principal: PRINCIPAL, input: { + surface: 'api', workspaceId: WORKSPACE_ID, knowledgeBaseIds: ['kb-1'], query: 'hello', diff --git a/apps/sim/app/api/v2/knowledge/search/route.ts b/apps/sim/app/api/v2/knowledge/search/route.ts index 4cb0bb11edb..28a122f0a5b 100644 --- a/apps/sim/app/api/v2/knowledge/search/route.ts +++ b/apps/sim/app/api/v2/knowledge/search/route.ts @@ -14,6 +14,7 @@ export const V2_KNOWLEDGE_SEARCH_MAX_BODY_BYTES = 2 * 1024 * 1024 export const POST = defineV2JsonRoute({ contract: v2SearchKnowledgeContract, auth: v2ApiKeyAuth, + /** Search is resource-read-only even though metering writes a usage record. */ operation: knowledgeOperations.search, rateLimit: v2RateLimits.publicApi, errorPolicy: v2KnowledgeErrorPolicies.concealKnowledgeBaseUsageAuthorization, @@ -27,6 +28,7 @@ export const POST = defineV2JsonRoute({ : [body.knowledgeBaseIds], query: body.query, topK: body.topK, + surface: 'api' as const, tagFilters: body.tagFilters, searchMode: body.searchMode, rerankerEnabled: body.rerankerEnabled, diff --git a/apps/sim/app/api/v2/lib/response.test.ts b/apps/sim/app/api/v2/lib/response.test.ts index b45aa898340..ad892f4974e 100644 --- a/apps/sim/app/api/v2/lib/response.test.ts +++ b/apps/sim/app/api/v2/lib/response.test.ts @@ -2,8 +2,33 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' +import { InsufficientScopeError } from '@/lib/core/application' import { HttpError } from '@/lib/core/utils/http-error' -import { v2Error, v2HttpError, v2RateLimitError } from '@/app/api/v2/lib/response' +import { + v2CaughtOrchestrationError, + v2Error, + v2HttpError, + v2RateLimitError, +} from '@/app/api/v2/lib/response' + +describe('v2 403 insufficient_scope challenge', () => { + /** + * RFC 6750 §3.1: a token that authenticated but lacks the scope gets a 403 + * whose challenge names the scope to ask for, alongside the closed detail + * code so a client can branch without parsing prose. + */ + it('names the missing scope in WWW-Authenticate and the detail code', async () => { + const response = v2CaughtOrchestrationError(new InsufficientScopeError('api:write')) + + expect(response?.status).toBe(403) + expect(response?.headers.get('WWW-Authenticate')).toBe( + 'Bearer realm="Sim API", error="insufficient_scope", scope="api:write"' + ) + await expect(response?.json()).resolves.toMatchObject({ + error: { code: 'FORBIDDEN', details: { code: 'INSUFFICIENT_SCOPE' } }, + }) + }) +}) describe('v2Error retry guidance', () => { it('sends Retry-After on 503 so a client does not retry a degraded dependency immediately', () => { @@ -62,10 +87,12 @@ describe('v2 401 authentication challenge', () => { * without a test noticing. The reachability tests below stay loose on purpose * — they pin that the header arrives down each path, not its value twice. */ - const EXPECTED_CHALLENGE = 'SimApiKey realm="Sim API", header="x-api-key"' + const EXPECTED_CHALLENGE = 'SimApiKey realm="Sim API", header="x-api-key", Bearer realm="Sim API"' const challenge = () => - v2Error('UNAUTHORIZED', 'API key required').headers.get('WWW-Authenticate') + v2Error('UNAUTHORIZED', 'API key or OAuth access token required').headers.get( + 'WWW-Authenticate' + ) it('sends a challenge on 401', () => { const response = v2Error('UNAUTHORIZED', 'Invalid API key') @@ -74,6 +101,23 @@ describe('v2 401 authentication challenge', () => { expect(response.headers.get('WWW-Authenticate')).toBe(EXPECTED_CHALLENGE) }) + /** + * RFC 6750 §3.1: `error="invalid_token"` only when a bearer token was + * presented and refused. A caller that sent nothing is told what would work, + * and the scheme it tried leads the list. + */ + it('leads with an invalid_token bearer challenge when a bearer token was refused', () => { + const response = v2Error('UNAUTHORIZED', 'Invalid access token', { authChallenge: 'bearer' }) + + expect(response.headers.get('WWW-Authenticate')).toBe( + 'Bearer realm="Sim API", error="invalid_token", SimApiKey realm="Sim API", header="x-api-key"' + ) + }) + + it('never marks a bearer token invalid when none was presented', () => { + expect(challenge()).not.toContain('invalid_token') + }) + it('names the x-api-key header, the only channel v2 actually reads', () => { expect(challenge()).toContain('x-api-key') }) diff --git a/apps/sim/app/api/v2/lib/response.ts b/apps/sim/app/api/v2/lib/response.ts index b259db824e4..30eaa7bb044 100644 --- a/apps/sim/app/api/v2/lib/response.ts +++ b/apps/sim/app/api/v2/lib/response.ts @@ -9,7 +9,11 @@ import { REFILTERED_CURSOR_MESSAGE, UNREADABLE_CURSOR_MESSAGE } from '@/lib/api/ import { type CursorKey, INVALID_CURSOR_MESSAGE } from '@/lib/api/list-query' import { getValidationErrorMessage, serializeZodIssues } from '@/lib/api/server' import { ADMISSION_RETRY_AFTER_SECONDS } from '@/lib/core/admission/transient-failure' -import { forbiddenErrorDetails } from '@/lib/core/application' +import { + forbiddenErrorDetails, + InsufficientScopeError, + OAuthAccessTokenExpiredError, +} from '@/lib/core/application' import { asOrchestrationError, OrchestrationError, @@ -71,26 +75,54 @@ const RETRY_AFTER_SECONDS_BY_STATUS: Partial> = { } /** - * The challenge every v2 `401` carries, so a 401 is a complete one. - * - * RFC 9110 §11.6.1 makes `WWW-Authenticate` a MUST on 401 — a 401 without it is - * a refusal that never says what would have been accepted, and a generic HTTP - * client has nothing to react to. + * The API-key half of every v2 `401` challenge. * * The scheme name is deliberately Sim-specific rather than a registered one. - * v2 authenticates from the `x-api-key` header and accepts no `Authorization` - * scheme at all — `Authorization: Bearer ` is not a channel here — so - * `Bearer` and `Basic` would both be false advertising. `Basic` is worse than - * false: a browser reacts to it by opening a native credential prompt that - * cannot produce an API key. An unregistered scheme is what remains, and it is - * legal: §11.6.1's grammar requires *an* `auth-scheme` token, not a registered - * one. Every challenge implies "retry via `Authorization: …`" by - * construction, so the token is chosen to be one no client has a built-in - * handler for — the challenge surfaces to a human instead of triggering an - * automatic retry down a channel v2 does not read — and the real channel is - * named outright in the `header` parameter beside it. + * An API key travels in `x-api-key`, not in `Authorization`, so `Basic` would + * be false advertising — and worse than false: a browser reacts to it by + * opening a native credential prompt that cannot produce an API key. An + * unregistered scheme is legal (RFC 9110 §11.6.1's grammar requires *an* + * `auth-scheme` token, not a registered one) and is chosen so no client has a + * built-in handler for it: the challenge surfaces to a human, and the real + * channel is named outright in the `header` parameter beside it. + */ +const V2_API_KEY_CHALLENGE = 'SimApiKey realm="Sim API", header="x-api-key"' + +/** + * The `WWW-Authenticate` value for a v2 `401`, so a 401 is a complete one. + * + * RFC 9110 §11.6.1 makes the header a MUST on 401 — a 401 without it is a + * refusal that never says what would have been accepted. v2 reads two + * credentials, so the header lists two challenges (§11.6.1 allows a list): + * `Bearer`, which is a registered scheme because Sim's OAuth access tokens + * really do travel as `Authorization: Bearer`, and the API-key scheme above. + * + * The one the caller tried leads, and only a bearer that was presented and + * refused carries `error="invalid_token"` (RFC 6750 §3.1): a request that sent + * nothing is told what would work, not what was wrong with a token it never + * offered. */ -const V2_AUTH_CHALLENGE = 'SimApiKey realm="Sim API", header="x-api-key"' +function v2AuthChallenge(tried: 'api_key' | 'bearer' = 'api_key'): string { + const bearer = + tried === 'bearer' ? 'Bearer realm="Sim API", error="invalid_token"' : 'Bearer realm="Sim API"' + return tried === 'bearer' + ? `${bearer}, ${V2_API_KEY_CHALLENGE}` + : `${V2_API_KEY_CHALLENGE}, ${bearer}` +} + +/** + * The `403` for a bearer token that authenticated but was not granted the scope + * the request needs. The challenge names the scope to ask for (RFC 6750 §3.1) + * and the detail code lets a client branch without parsing prose. + */ +export function v2InsufficientScope(error: InsufficientScopeError): NextResponse { + return v2Error('FORBIDDEN', error.message, { + details: { code: error.detailCode }, + headers: { + 'WWW-Authenticate': `Bearer realm="Sim API", error="insufficient_scope", scope="${error.requiredScope}"`, + }, + }) +} type RateLimitHeaderSource = Pick @@ -142,6 +174,8 @@ interface V2ErrorOptions { status?: number details?: unknown headers?: Record + /** For a 401: which credential the caller presented, so its challenge leads. */ + authChallenge?: 'api_key' | 'bearer' /** * Suppresses the code's default `Retry-After` for a failure whose outcome is * *unknown* rather than *absent*. @@ -175,7 +209,7 @@ export function v2Error( status, headers: { ...PRIVATE_NO_STORE, - ...(status === 401 ? { 'WWW-Authenticate': V2_AUTH_CHALLENGE } : {}), + ...(status === 401 ? { 'WWW-Authenticate': v2AuthChallenge(options.authChallenge) } : {}), ...(retryAfterSeconds === undefined ? {} : { 'Retry-After': retryAfterSeconds.toString() }), ...options.headers, }, @@ -498,6 +532,10 @@ export function v2ErrorForOrchestration( export function v2CaughtOrchestrationError(error: unknown): NextResponse | null { const classified = asOrchestrationError(error) if (!classified) return null + if (classified instanceof InsufficientScopeError) return v2InsufficientScope(classified) + if (classified instanceof OAuthAccessTokenExpiredError) { + return v2Error('UNAUTHORIZED', classified.message, { authChallenge: 'bearer' }) + } return v2ErrorForOrchestration( classified.code, classified.message, diff --git a/apps/sim/app/api/v2/mcp-servers/[mcpServerId]/route.ts b/apps/sim/app/api/v2/mcp-servers/[mcpServerId]/route.ts index e2cb7d09c6f..d81ec057a3d 100644 --- a/apps/sim/app/api/v2/mcp-servers/[mcpServerId]/route.ts +++ b/apps/sim/app/api/v2/mcp-servers/[mcpServerId]/route.ts @@ -1,3 +1,4 @@ +import { isUserCredentialPrincipal } from '@sim/auth/principal' import { v2DeleteMcpServerContract, v2GetMcpServerContract, @@ -61,7 +62,7 @@ export const DELETE = defineV2JsonRoute({ }), useCase: deleteMcpServerUseCase, onSuccess: ({ principal, input, result }) => { - if (principal.kind !== 'personal_api_key') return + if (!isUserCredentialPrincipal(principal)) return captureServerEvent( principal.userId, 'mcp_server_disconnected', diff --git a/apps/sim/app/api/v2/mcp-servers/route.ts b/apps/sim/app/api/v2/mcp-servers/route.ts index 91ea160afc2..38484db7dba 100644 --- a/apps/sim/app/api/v2/mcp-servers/route.ts +++ b/apps/sim/app/api/v2/mcp-servers/route.ts @@ -1,3 +1,4 @@ +import { isUserCredentialPrincipal } from '@sim/auth/principal' import { v2CreateMcpServerContract, v2ListMcpServersContract, @@ -68,7 +69,7 @@ export const POST = defineV2JsonRoute({ mapInput: ({ body }) => ({ ...body, source: 'api' as const }), useCase: createMcpServerUseCase, onSuccess: ({ principal, input, result }) => { - if (principal.kind !== 'personal_api_key' || result.updated) return + if (!isUserCredentialPrincipal(principal) || result.updated) return captureServerEvent( principal.userId, 'mcp_server_connected', diff --git a/apps/sim/app/api/v2/meta/route.test.ts b/apps/sim/app/api/v2/meta/route.test.ts index 56ce546cdbf..aa6b7f2f353 100644 --- a/apps/sim/app/api/v2/meta/route.test.ts +++ b/apps/sim/app/api/v2/meta/route.test.ts @@ -81,7 +81,7 @@ describe('GET /api/v2/meta', () => { it('requires authentication', async () => { v2RouteMocks.authenticate.mockRejectedValue( - new MockV2ApiKeyUnauthenticatedError('API key required') + new MockV2ApiKeyUnauthenticatedError('API key or OAuth access token required') ) const response = await GET(new NextRequest('http://localhost:3000/api/v2/meta')) diff --git a/apps/sim/app/api/v2/meta/route.ts b/apps/sim/app/api/v2/meta/route.ts index 7dbf75b0c2d..b32be28d85a 100644 --- a/apps/sim/app/api/v2/meta/route.ts +++ b/apps/sim/app/api/v2/meta/route.ts @@ -11,7 +11,7 @@ import { export const dynamic = 'force-dynamic' export const revalidate = 0 -/** GET /api/v2/meta — Report the calling key's API availability and lifecycle. */ +/** GET /api/v2/meta — Report the calling credential's API availability and lifecycle. */ export const GET = defineV2JsonRoute({ contract: v2GetMetaContract, auth: v2ApiKeyAuth, diff --git a/apps/sim/app/api/v2/skills/[skillId]/editors/route.ts b/apps/sim/app/api/v2/skills/[skillId]/editors/route.ts index dbd76d080b3..d5feeb07d91 100644 --- a/apps/sim/app/api/v2/skills/[skillId]/editors/route.ts +++ b/apps/sim/app/api/v2/skills/[skillId]/editors/route.ts @@ -1,3 +1,4 @@ +import { isUserCredentialPrincipal } from '@sim/auth/principal' import { type V2SkillEditor, v2GrantSkillEditorContract, @@ -88,7 +89,7 @@ export const POST = defineV2JsonRoute({ }), useCase: grantSkillEditorUseCase, onSuccess: ({ principal, input, result }) => { - if (!result.created || principal.kind !== 'personal_api_key') return + if (!result.created || !isUserCredentialPrincipal(principal)) return captureServerEvent( principal.userId, 'skill_shared', @@ -113,7 +114,7 @@ export const DELETE = defineV2JsonRoute({ }), useCase: revokeSkillEditorUseCase, onSuccess: ({ principal, input, result }) => { - if (principal.kind !== 'personal_api_key') return + if (!isUserCredentialPrincipal(principal)) return captureServerEvent( principal.userId, 'skill_unshared', diff --git a/apps/sim/app/api/v2/skills/[skillId]/route.ts b/apps/sim/app/api/v2/skills/[skillId]/route.ts index 6414280f4c4..7786b43d439 100644 --- a/apps/sim/app/api/v2/skills/[skillId]/route.ts +++ b/apps/sim/app/api/v2/skills/[skillId]/route.ts @@ -1,3 +1,4 @@ +import { isUserCredentialPrincipal } from '@sim/auth/principal' import { v2DeleteSkillContract, v2GetSkillContract, @@ -51,7 +52,7 @@ export const PATCH = defineV2JsonRoute({ }), useCase: updateSkillUseCase, onSuccess: ({ principal, input, result }) => { - if (principal.kind !== 'personal_api_key') return + if (!isUserCredentialPrincipal(principal)) return captureServerEvent( principal.userId, 'skill_updated', @@ -81,7 +82,7 @@ export const DELETE = defineV2JsonRoute({ }), useCase: deleteSkillUseCase, onSuccess: ({ principal, input, result }) => { - if (principal.kind !== 'personal_api_key') return + if (!isUserCredentialPrincipal(principal)) return captureServerEvent( principal.userId, 'skill_deleted', diff --git a/apps/sim/app/api/v2/skills/route.ts b/apps/sim/app/api/v2/skills/route.ts index 42de2765968..e0d932d4577 100644 --- a/apps/sim/app/api/v2/skills/route.ts +++ b/apps/sim/app/api/v2/skills/route.ts @@ -1,3 +1,4 @@ +import { isUserCredentialPrincipal } from '@sim/auth/principal' import { v2CreateSkillContract, v2ListSkillsContract } from '@/lib/api/contracts/v2/skills' import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding' import { @@ -69,7 +70,7 @@ export const POST = defineV2JsonRoute({ mapInput: ({ body }) => ({ ...body, source: 'api' as const }), useCase: createSkillUseCase, onSuccess: ({ principal, input, result }) => { - if (principal.kind !== 'personal_api_key') return + if (!isUserCredentialPrincipal(principal)) return captureServerEvent( principal.userId, 'skill_created', diff --git a/apps/sim/app/api/v2/tables/[tableId]/query/count/route.ts b/apps/sim/app/api/v2/tables/[tableId]/query/count/route.ts index f71712224dc..6bb775e1719 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/query/count/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/query/count/route.ts @@ -24,6 +24,7 @@ export const revalidate = 0 export const POST = defineV2JsonRoute({ contract: v2QueryRowsCountContract, operation: tableOperations.queryRows, + /** POST carries structured filters but performs a read-only query. */ auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, errorPolicy: v2TableRowsErrorPolicy, diff --git a/apps/sim/app/api/v2/tables/[tableId]/query/route.ts b/apps/sim/app/api/v2/tables/[tableId]/query/route.ts index 79695721060..6b1046d5e02 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/query/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/query/route.ts @@ -27,6 +27,7 @@ function queryRowCursorScope(tableId: string): string { export const POST = defineV2JsonRoute({ contract: v2QueryRowsContract, operation: tableOperations.queryRows, + /** POST carries structured filters but performs a read-only query. */ auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, errorPolicy: v2TableRowsErrorPolicy, diff --git a/apps/sim/app/api/v2/tables/[tableId]/rows/search/route.ts b/apps/sim/app/api/v2/tables/[tableId]/rows/search/route.ts index ed0ca3ea5d7..09342964958 100644 --- a/apps/sim/app/api/v2/tables/[tableId]/rows/search/route.ts +++ b/apps/sim/app/api/v2/tables/[tableId]/rows/search/route.ts @@ -11,6 +11,7 @@ export const revalidate = 0 export const POST = defineV2JsonRoute({ contract: v2SearchTableRowsContract, operation: tableOperations.searchRows, + /** POST carries structured filters but performs a read-only search. */ auth: v2ApiKeyAuth, rateLimit: v2RateLimits.publicApi, errorPolicy: v2TableRowsErrorPolicy, diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/deploy/route.ts b/apps/sim/app/api/v2/workflows/[workflowId]/deploy/route.ts index 59aa0fd0e9d..6dc563160c9 100644 --- a/apps/sim/app/api/v2/workflows/[workflowId]/deploy/route.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/deploy/route.ts @@ -1,3 +1,4 @@ +import { isUserCredentialPrincipal } from '@sim/auth/principal' import { v2DeployWorkflowContract, v2UndeployWorkflowContract, @@ -67,7 +68,7 @@ export const DELETE = defineV2JsonRoute({ * would report a succeeded undeploy as a 500 rather than catching anything. */ onSuccess: ({ principal, result }) => { - if (principal.kind !== 'personal_api_key') return + if (!isUserCredentialPrincipal(principal)) return captureServerEvent( principal.userId, 'workflow_undeployed', diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/deployments/chat/route.ts b/apps/sim/app/api/v2/workflows/[workflowId]/deployments/chat/route.ts index 13ad41bfe26..24a0f5bb46e 100644 --- a/apps/sim/app/api/v2/workflows/[workflowId]/deployments/chat/route.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/deployments/chat/route.ts @@ -5,11 +5,11 @@ import { } from '@/lib/api/contracts/v2/chat-deployments' import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' import { - chatDeploymentOperations, deleteWorkflowChatDeployment, readWorkflowChatDeployment, replaceWorkflowChatDeployment, } from '@/lib/chat-deployments/application' +import { chatDeploymentOperations } from '@/lib/chat-deployments/application/operations' import { generateRequestId } from '@/lib/core/utils/request' import { chatDeploymentErrorPolicy, toV2ChatDeployment } from '@/app/api/v2/chat-deployments/utils' diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.test.ts b/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.test.ts index 6449e170f57..323a43fd62d 100644 --- a/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.test.ts @@ -20,6 +20,7 @@ import { WorkspaceApiKeyAuthorizationError } from '@/lib/core/application' const { MockV2ApiKeyUnauthenticatedError, + mockAdmissionRelease, mockAuthenticateV2ApiKey, mockClaimExecutionId, mockCheckOperationRate, @@ -35,6 +36,7 @@ const { mockValidatePublicApiAllowed, } = vi.hoisted(() => ({ MockV2ApiKeyUnauthenticatedError: class MockV2ApiKeyUnauthenticatedError extends Error {}, + mockAdmissionRelease: vi.fn(), mockAuthenticateV2ApiKey: vi.fn(), mockClaimExecutionId: vi.fn(), mockCheckOperationRate: vi.fn(), @@ -50,6 +52,10 @@ const { mockValidatePublicApiAllowed: vi.fn(), })) +vi.mock('@/lib/core/admission/gate', () => ({ + tryAdmit: vi.fn(() => ({ release: mockAdmissionRelease })), +})) + vi.mock('@/lib/workflows/application/execute-manual-workflow', () => ({ executeManualWorkflowOperation: { execute: mockExecuteManualTrigger }, executeManualWorkflowFromBlockOperation: { execute: mockExecuteManualFromBlock }, @@ -218,6 +224,14 @@ function callPublicExecute(body: Record, headers: Record) { + const req = createMockRequest('POST', body, { + 'Content-Type': 'application/json', + Authorization: 'Bearer sim_oat_token', + }) + return POST(req, { params: Promise.resolve({ workflowId: 'workflow-1' }) }) +} + /** * Queues the two reads the anonymous public path makes, in order: the workflow's * public-API eligibility, then the workspace billing account it runs as. Keeping @@ -345,6 +359,128 @@ describe('POST /api/v2/workflows/[workflowId]/execute', () => { }) }) + it('streams an immediate heartbeat and the same sync result when NDJSON is accepted', async () => { + vi.useFakeTimers() + try { + let finishExecution!: (result: unknown) => void + mockExecuteWorkflowCore.mockReturnValueOnce( + new Promise((resolve) => { + finishExecution = resolve + }) + ) + + const response = await callExecute( + { input: { hello: 'world' } }, + { Accept: 'application/x-ndjson' } + ) + + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toContain('application/x-ndjson') + expect(response.headers.get('X-Run-Id')).toBe('execution-123') + if (!response.body) throw new Error('Expected NDJSON response body') + const reader = response.body.getReader() + const decoder = new TextDecoder() + expect(JSON.parse(decoder.decode((await reader.read()).value))).toMatchObject({ + type: 'heartbeat', + }) + expect(mockAdmissionRelease).not.toHaveBeenCalled() + expect(mockReleaseExecutionIdClaim).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(15_000) + expect(JSON.parse(decoder.decode((await reader.read()).value))).toMatchObject({ + type: 'heartbeat', + }) + + finishExecution({ + success: true, + output: { result: 'done' }, + metadata: { + duration: 42, + startTime: '2026-07-31T00:00:00.000Z', + endTime: '2026-07-31T00:00:01.000Z', + }, + }) + + expect(JSON.parse(decoder.decode((await reader.read()).value))).toEqual({ + type: 'final', + data: { + runId: 'execution-123', + workflowId: 'workflow-1', + status: 'completed', + output: { result: 'done' }, + error: null, + startedAt: '2026-07-31T00:00:00.000Z', + endedAt: '2026-07-31T00:00:01.000Z', + durationMs: 42, + }, + }) + expect((await reader.read()).done).toBe(true) + expect(mockAdmissionRelease).toHaveBeenCalledTimes(1) + expect(mockReleaseExecutionIdClaim).toHaveBeenCalledTimes(1) + } finally { + vi.useRealTimers() + } + }) + + it('keeps the JSON response when NDJSON is explicitly rejected', async () => { + const response = await callExecute( + { input: { hello: 'world' } }, + { Accept: 'application/json, application/x-ndjson;q=0' } + ) + + expect(response.headers.get('content-type')).toContain('application/json') + expect(await response.json()).toMatchObject({ + data: { runId: 'execution-123', status: 'completed' }, + }) + }) + + it('uses the heartbeat result transport for a manual draft run', async () => { + authenticatePersonalKey() + let finishExecution!: (result: unknown) => void + const pending = new Promise((resolve) => { + finishExecution = resolve + }) + mockExecuteManualTrigger.mockResolvedValueOnce({ + ok: true, + executionId: 'execution-123', + pending, + cancel: vi.fn(), + }) + + const response = await callExecute( + { run: { source: 'manual' }, input: { hello: 'world' } }, + { Accept: 'application/x-ndjson' } + ) + + expect(response.headers.get('content-type')).toContain('application/x-ndjson') + expect(mockExecuteManualTrigger).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ mode: 'sync-result-stream' }), + }) + ) + if (!response.body) throw new Error('Expected NDJSON response body') + const reader = response.body.getReader() + await reader.read() + + finishExecution({ + ok: true, + executionId: 'execution-123', + workflowId: 'workflow-1', + status: 'completed', + aborted: null, + output: { result: 'manual done' }, + error: null, + hasResponseBlock: false, + }) + + const final = JSON.parse(new TextDecoder().decode((await reader.read()).value)) + expect(final).toMatchObject({ + type: 'final', + data: { runId: 'execution-123', output: { result: 'manual done' } }, + }) + expect((await reader.read()).done).toBe(true) + expect(mockAdmissionRelease).toHaveBeenCalledTimes(1) + }) + it('returns status failed with a structured error instead of an HTTP error', async () => { const error = new Error('Send Email: Invalid credentials') Object.assign(error, { blockId: 'block-9', blockName: 'Send Email', blockType: 'gmail' }) @@ -468,6 +604,32 @@ describe('POST /api/v2/workflows/[workflowId]/execute', () => { expect(mockExecuteWorkflowCore).not.toHaveBeenCalled() }) + it('dispatches an OAuth manual trigger run through the same manual operation', async () => { + mockAuthenticateV2ApiKey.mockResolvedValue({ + principal: { + kind: 'oauth_access_token', + userId: 'actor-1', + clientId: 'sim-cli', + tokenId: 'token-1', + scopes: ['api:write'], + expiresAt: new Date('2099-01-01T00:00:00.000Z'), + }, + rateLimitSubjectIds: ['oauth-token:token-1', 'user:actor-1'], + rateLimitSubscription: null, + keyType: 'oauth_access_token', + keyExpiresAt: new Date('2099-01-01T00:00:00.000Z'), + }) + + const response = await callOAuthExecute({ run: { source: 'manual' } }) + + expect(response.status).toBe(200) + expect(mockExecuteManualTrigger).toHaveBeenCalledWith( + expect.objectContaining({ + principal: expect.objectContaining({ kind: 'oauth_access_token', clientId: 'sim-cli' }), + }) + ) + }) + it('returns the typed workspace-key denial for manual execution', async () => { mockExecuteManualTrigger.mockRejectedValueOnce(new WorkspaceApiKeyAuthorizationError()) @@ -514,6 +676,7 @@ describe('POST /api/v2/workflows/[workflowId]/execute', () => { expect(response.status).toBe(200) expect(response.headers.get('Content-Type')).toContain('text/event-stream') + expect(response.headers.get('X-Run-Id')).toBe('execution-123') expect(await response.text()).toBe('data: "[DONE]"\n\n') expect(mockExecuteManualTrigger).toHaveBeenCalledWith( expect.objectContaining({ input: expect.objectContaining({ mode: 'stream' }) }) @@ -826,7 +989,7 @@ describe('POST /api/v2/workflows/[workflowId]/execute', () => { expect(response.status).toBe(401) expect((await response.json()).error.message).toBe( - 'Manual execution requires a personal API key' + 'Manual execution requires an OAuth access token or personal API key' ) expect(mockExecuteManualTrigger).not.toHaveBeenCalled() }) diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.ts b/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.ts index b3271d894a1..db03a7bd2db 100644 --- a/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.ts @@ -25,6 +25,7 @@ import { getWorkspaceBilledAccountUserId } from '@/lib/billing/core/billing-attr import { tryAdmit } from '@/lib/core/admission/gate' import { ADMISSION_ERROR_DESCRIPTOR } from '@/lib/core/admission/transient-failure' import type { ForbiddenDetailCode } from '@/lib/core/application' +import { acceptsMediaType } from '@/lib/core/utils/media-types' import { generateRequestId } from '@/lib/core/utils/request' import { getBaseUrl } from '@/lib/core/utils/urls' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -43,7 +44,9 @@ import { executeWorkflowOperation } from '@/lib/workflows/application/execute-wo import { workflowOperations } from '@/lib/workflows/application/operations' import { type ExecuteWorkflowServiceFailure, + type ExecuteWorkflowServicePendingRun, type ExecuteWorkflowServiceResult, + type ExecuteWorkflowServiceRun, executeWorkflowService, } from '@/lib/workflows/executor/execute-service' import { @@ -60,6 +63,10 @@ import { const logger = createLogger('V2WorkflowExecuteAPI') +const WORKFLOW_RESULT_STREAM_CONTENT_TYPE = 'application/x-ndjson' +const WORKFLOW_RESULT_HEARTBEAT_INTERVAL_MS = 15_000 +const ndjsonEncoder = new TextEncoder() + export const runtime = 'nodejs' export const dynamic = 'force-dynamic' @@ -103,6 +110,124 @@ function serviceFailureResponse(failure: ExecuteWorkflowServiceFailure) { }) } +function wantsResultStream(req: NextRequest): boolean { + return acceptsMediaType(req.headers.get('accept'), WORKFLOW_RESULT_STREAM_CONTENT_TYPE) +} + +function encodeNdjson(value: unknown): Uint8Array { + return ndjsonEncoder.encode(`${JSON.stringify(value)}\n`) +} + +function presentRun(result: ExecuteWorkflowServiceRun) { + return { + runId: result.executionId, + workflowId: result.workflowId, + status: result.status, + output: result.output ?? null, + error: result.error, + startedAt: result.startedAt, + endedAt: result.endedAt, + durationMs: result.durationMs, + } +} + +/** + * Presents an ordinary synchronous run as heartbeat-delimited NDJSON. The + * execution promise is the same one used by the JSON path; only its response + * framing changes, so manual draft selection and cancellation keep their + * existing semantics. + */ +function streamPendingRun( + pendingRun: ExecuteWorkflowServicePendingRun, + requestId: string, + onSettled: () => void +): Response { + let cancelled = false + let heartbeatId: ReturnType | undefined + const stopHeartbeat = () => { + if (heartbeatId) { + clearInterval(heartbeatId) + heartbeatId = undefined + } + } + + const stream = new ReadableStream({ + start(controller) { + const cancel = () => { + if (cancelled) return + cancelled = true + stopHeartbeat() + pendingRun.cancel() + } + const send = (event: unknown): boolean => { + if (cancelled) return false + try { + controller.enqueue(encodeNdjson(event)) + return true + } catch { + cancel() + return false + } + } + + if (send({ type: 'heartbeat', timestamp: new Date().toISOString() })) { + heartbeatId = setInterval(() => { + send({ type: 'heartbeat', timestamp: new Date().toISOString() }) + }, WORKFLOW_RESULT_HEARTBEAT_INTERVAL_MS) + } + + void pendingRun.pending + .then((result) => { + if (!result.ok) { + send({ + type: 'error', + error: result.failure.message, + code: result.failure.code, + status: result.failure.statusCode, + }) + return + } + if (result.aborted === 'client') { + send({ + type: 'error', + error: 'Client cancelled request', + code: 'CLIENT_CLOSED_REQUEST', + status: 499, + }) + return + } + send({ type: 'final', data: presentRun(result) }) + }) + .catch((error) => { + logger.error(`[${requestId}] v2 execute result stream failed`, { + error: getErrorMessage(error, 'Unknown error'), + }) + send({ type: 'error', error: 'Internal server error', status: 500 }) + }) + .finally(() => { + stopHeartbeat() + onSettled() + if (!cancelled) controller.close() + }) + }, + cancel() { + cancelled = true + stopHeartbeat() + pendingRun.cancel() + }, + }) + + return new Response(stream, { + headers: { + 'Content-Type': `${WORKFLOW_RESULT_STREAM_CONTENT_TYPE}; charset=utf-8`, + 'Cache-Control': 'no-cache, no-transform', + 'X-Accel-Buffering': 'no', + Vary: 'Accept', + [V2_WORKFLOW_RUN_ID_HEADER]: pendingRun.executionId, + }, + }) +} + /** * Path parameters read straight from the Next context, typed from the contract * rather than restated inline. @@ -129,7 +254,9 @@ type V2ExecuteWorkflowRouteContext = { * - `async: true` (body flag — v2 has no mode headers) → 202 * `{ data: { runId, statusUrl } }`; poll the v2 runs resource. * - `stream: true` → SSE passthrough (no `{data}` envelope on event frames). - * - Sync → 200 run resource with the status enum and structured error; + * - Sync → 200 run resource with the status enum and structured error. A caller + * that accepts `application/x-ndjson` receives heartbeat frames followed by + * the same resource in a `final` frame; * an in-band run failure is `status: 'failed'`, never an HTTP error. A * Response block's declared payload stays inside `output` — v2 never lets a * workflow author control response status or headers on this origin. @@ -216,6 +343,7 @@ export const POST = withRouteHandler( }) } + let ticketTransferred = false try { const parsed = await parseRequest(v2ExecuteWorkflowContract, req, context, { ...V2_PARSE_DEFAULTS, @@ -230,7 +358,10 @@ export const POST = withRouteHandler( const manualRun = body.run?.source === 'manual' ? body.run : undefined if (manualRun && !apiKeyPrincipal) { - return v2Error('UNAUTHORIZED', 'Manual execution requires a personal API key') + return v2Error( + 'UNAUTHORIZED', + 'Manual execution requires an OAuth access token or personal API key' + ) } if (manualRun && body.async) { return v2Error('BAD_REQUEST', 'Manual execution does not support async mode') @@ -244,7 +375,7 @@ export const POST = withRouteHandler( } if (body.async && isPublicApiAccess) { - return v2Error('BAD_REQUEST', 'Async execution requires an API key') + return v2Error('BAD_REQUEST', 'Async execution requires an OAuth access token or API key') } if (body.async && body.stream) { return v2Error('BAD_REQUEST', 'async and stream cannot be combined') @@ -295,6 +426,8 @@ export const POST = withRouteHandler( ) } + const resultStream = !body.async && !body.stream && wantsResultStream(req) + /** Caller-supplied run IDs are a keyed-caller feature; anonymous callers must not probe the claim table. */ let requestedExecutionId: string | undefined const runIdHeader = parsed.data.headers['x-run-id'] @@ -323,7 +456,7 @@ export const POST = withRouteHandler( input: { ...commonInput, input: body.input, - mode: body.stream ? 'stream' : 'sync', + mode: body.stream ? 'stream' : resultStream ? 'sync-result-stream' : 'sync', blockId: manualRun.entry.blockId, sourceRunId: manualRun.entry.sourceRunId, }, @@ -335,7 +468,7 @@ export const POST = withRouteHandler( input: { ...commonInput, input: body.input, - mode: body.stream ? 'stream' : 'sync', + mode: body.stream ? 'stream' : resultStream ? 'sync-result-stream' : 'sync', triggerBlockId: manualRun.entry?.blockId, useMockPayload: manualRun.entry?.useMockPayload === true, }, @@ -348,7 +481,13 @@ export const POST = withRouteHandler( ...commonInput, input: body.input ?? {}, requestedTimeoutSeconds: body.executionTimeoutSeconds, - mode: body.async ? 'async' : body.stream ? 'stream' : 'sync', + mode: body.async + ? 'async' + : body.stream + ? 'stream' + : resultStream + ? 'sync-result-stream' + : 'sync', }, request: req, }) @@ -397,7 +536,7 @@ export const POST = withRouteHandler( selectedOutputs: body.selectedOutputs, rateLimitCounter: 'sync', abortSignal: req.signal, - mode: body.stream ? 'stream' : 'sync', + mode: body.stream ? 'stream' : resultStream ? 'sync-result-stream' : 'sync', requestHeaders: req.headers, includeThinking: body.includeThinking, includeToolCalls: body.includeToolCalls, @@ -409,9 +548,20 @@ export const POST = withRouteHandler( return serviceFailureResponse(result.failure) } + if ('pending' in result) { + const response = streamPendingRun(result, requestId, ticket.release) + ticketTransferred = true + return response + } + if ('stream' in result) { - // SSE: pass the stream through byte-for-byte with its own headers. - return result.stream + const headers = new Headers(result.stream.headers) + headers.set(V2_WORKFLOW_RUN_ID_HEADER, result.executionId) + return new Response(result.stream.body, { + status: result.stream.status, + statusText: result.stream.statusText, + headers, + }) } if ('queued' in result) { @@ -430,19 +580,9 @@ export const POST = withRouteHandler( }) } - return v2Data( - { - runId: result.executionId, - workflowId: result.workflowId, - status: result.status, - output: result.output ?? null, - error: result.error, - startedAt: result.startedAt, - endedAt: result.endedAt, - durationMs: result.durationMs, - }, - { headers: { [V2_WORKFLOW_RUN_ID_HEADER]: result.executionId } } - ) + return v2Data(presentRun(result), { + headers: { [V2_WORKFLOW_RUN_ID_HEADER]: result.executionId }, + }) } catch (error) { const classified = v2WorkflowErrorPolicies.concealWorkflowAuthorization.render(error) if (classified) return classified @@ -452,7 +592,7 @@ export const POST = withRouteHandler( }) return v2Error('INTERNAL_ERROR', 'Internal server error') } finally { - ticket.release() + if (!ticketTransferred) ticket.release() } }, { diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/route.test.ts b/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/route.test.ts index c7c0db44799..bfd3758957a 100644 --- a/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/route.test.ts @@ -336,7 +336,7 @@ describe('v2 run detail and cancel adapters', () => { it('rejects missing API keys before reading the run', async () => { v2RouteMocks.authenticate.mockRejectedValueOnce( - new MockV2ApiKeyUnauthenticatedError('API key required') + new MockV2ApiKeyUnauthenticatedError('API key or OAuth access token required') ) const response = await callStatus() diff --git a/apps/sim/app/api/wand/route.ts b/apps/sim/app/api/wand/route.ts index 16743d93a6a..bf926e6a871 100644 --- a/apps/sim/app/api/wand/route.ts +++ b/apps/sim/app/api/wand/route.ts @@ -130,7 +130,7 @@ async function updateUserStatsForWand( await recordUsage({ userId: billingAttribution.actorUserId, - workspaceId: billingAttribution.workspaceId, + workspaceId: billingAttribution.workspaceId ?? undefined, ...toBillingContext(billingAttribution), entries: [ { diff --git a/apps/sim/app/api/webhooks/outbox/process/route.ts b/apps/sim/app/api/webhooks/outbox/process/route.ts index ea6746ff8f8..a955c51a990 100644 --- a/apps/sim/app/api/webhooks/outbox/process/route.ts +++ b/apps/sim/app/api/webhooks/outbox/process/route.ts @@ -14,6 +14,8 @@ import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { directGrantOutboxHandlers } from '@/lib/invitations/direct-grant' import { knowledgeDocumentProcessingOutboxHandlers } from '@/lib/knowledge/documents/processing-outbox-handler' +import { organizationResourceCleanupOutboxHandlers } from '@/lib/organizations/resource-cleanup' +import { workspaceFileLiveDocOutboxHandlers } from '@/lib/uploads/contexts/workspace/workspace-file-live-doc-outbox' import { workspaceFileStorageCleanupOutboxHandlers } from '@/lib/uploads/contexts/workspace/workspace-file-storage-cleanup-outbox' import { workflowDeploymentOutboxHandlers } from '@/lib/workflows/deployment-outbox' import { invitationMigrationOutboxHandlers } from '@/lib/workspaces/admin-move' @@ -34,6 +36,8 @@ const handlers = { ...invitationMigrationOutboxHandlers, ...directGrantOutboxHandlers, ...knowledgeDocumentProcessingOutboxHandlers, + ...organizationResourceCleanupOutboxHandlers, + ...workspaceFileLiveDocOutboxHandlers, ...workspaceFileStorageCleanupOutboxHandlers, ...workflowDeploymentOutboxHandlers, } as const diff --git a/apps/sim/app/api/webhooks/slack/route.test.ts b/apps/sim/app/api/webhooks/slack/route.test.ts index 0afabbc8bc4..2cf54aeb168 100644 --- a/apps/sim/app/api/webhooks/slack/route.test.ts +++ b/apps/sim/app/api/webhooks/slack/route.test.ts @@ -4,12 +4,19 @@ import { resetEnvMock, setEnv } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockParseWebhookBody, mockFindWebhooksByRoutingKey, mockDispatchResolvedWebhookTarget } = - vi.hoisted(() => ({ - mockParseWebhookBody: vi.fn(), - mockFindWebhooksByRoutingKey: vi.fn(), - mockDispatchResolvedWebhookTarget: vi.fn(), - })) +const { + mockParseWebhookBody, + mockFindWebhooksByRoutingKey, + mockDispatchResolvedWebhookTarget, + mockHandleSlackChallenge, + mockVerifySlackRequestSignature, +} = vi.hoisted(() => ({ + mockParseWebhookBody: vi.fn(), + mockFindWebhooksByRoutingKey: vi.fn(), + mockDispatchResolvedWebhookTarget: vi.fn(), + mockHandleSlackChallenge: vi.fn(), + mockVerifySlackRequestSignature: vi.fn(), +})) vi.mock('@/lib/core/admission/gate', () => ({ tryAdmit: () => ({ release: vi.fn() }), @@ -23,8 +30,8 @@ vi.mock('@/lib/webhooks/processor', () => ({ })) vi.mock('@/lib/webhooks/providers/slack', () => ({ - handleSlackChallenge: () => null, - verifySlackRequestSignature: () => null, + handleSlackChallenge: mockHandleSlackChallenge, + verifySlackRequestSignature: mockVerifySlackRequestSignature, resolveSlackEventKey: () => null, })) @@ -60,6 +67,8 @@ describe('Slack app webhook route', () => { beforeEach(() => { vi.clearAllMocks() setEnv({ SLACK_SIGNING_SECRET: 'test-secret' }) + mockHandleSlackChallenge.mockReturnValue(null) + mockVerifySlackRequestSignature.mockReturnValue(null) mockFindWebhooksByRoutingKey.mockResolvedValue([webhook('wh1')]) mockDispatchResolvedWebhookTarget.mockResolvedValue({ outcome: 'queued', @@ -70,9 +79,63 @@ describe('Slack app webhook route', () => { it('dispatches each webhook resolved for the event team', async () => { await run(messageBody) + expect(mockVerifySlackRequestSignature).toHaveBeenCalledWith( + 'test-secret', + expect.anything(), + JSON.stringify(messageBody), + expect.any(String) + ) expect(mockDispatchResolvedWebhookTarget).toHaveBeenCalledTimes(1) }) + it('rejects a verification challenge when the native app is not configured', async () => { + setEnv({ SLACK_SIGNING_SECRET: undefined }) + mockHandleSlackChallenge.mockReturnValue(new Response('challenge', { status: 200 })) + + const response = await run({ type: 'url_verification', challenge: 'challenge' }) + + expect(response.status).toBe(500) + expect(mockVerifySlackRequestSignature).not.toHaveBeenCalled() + expect(mockHandleSlackChallenge).not.toHaveBeenCalled() + }) + + it('treats a whitespace-only native signing secret as unconfigured', async () => { + setEnv({ SLACK_SIGNING_SECRET: ' ' }) + + const response = await run(messageBody) + + expect(response.status).toBe(500) + expect(mockVerifySlackRequestSignature).not.toHaveBeenCalled() + expect(mockFindWebhooksByRoutingKey).not.toHaveBeenCalled() + }) + + it('verifies a signed request before answering the verification challenge', async () => { + const body = { type: 'url_verification', challenge: 'challenge' } + mockHandleSlackChallenge.mockReturnValue(new Response('challenge', { status: 200 })) + + const response = await run(body) + + expect(mockVerifySlackRequestSignature).toHaveBeenCalledWith( + 'test-secret', + expect.anything(), + JSON.stringify(body), + expect.any(String) + ) + expect(mockHandleSlackChallenge).toHaveBeenCalledWith(body) + expect(response.status).toBe(200) + expect(mockFindWebhooksByRoutingKey).not.toHaveBeenCalled() + }) + + it('does not answer a verification challenge with an invalid signature', async () => { + mockVerifySlackRequestSignature.mockReturnValue(new Response(null, { status: 401 })) + mockHandleSlackChallenge.mockReturnValue(new Response('challenge', { status: 200 })) + + const response = await run({ type: 'url_verification', challenge: 'challenge' }) + + expect(response.status).toBe(401) + expect(mockHandleSlackChallenge).not.toHaveBeenCalled() + }) + it('continues cleanly when the dispatcher filters the event', async () => { mockDispatchResolvedWebhookTarget.mockResolvedValue({ outcome: 'ignored', diff --git a/apps/sim/app/api/webhooks/slack/route.ts b/apps/sim/app/api/webhooks/slack/route.ts index 80d06c7d9fb..49223d4c4ef 100644 --- a/apps/sim/app/api/webhooks/slack/route.ts +++ b/apps/sim/app/api/webhooks/slack/route.ts @@ -1,12 +1,12 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { admissionRejectedResponse, tryAdmit } from '@/lib/core/admission/gate' -import { env } from '@/lib/core/config/env' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { findWebhooksByRoutingKey, parseWebhookBody } from '@/lib/webhooks/processor' import { handleSlackChallenge, verifySlackRequestSignature } from '@/lib/webhooks/providers/slack' import { dispatchSlackWebhooks, getSlackDispatchResponse } from '@/lib/webhooks/slack-dispatch' +import { getSlackNativeSigningSecret } from '@/lib/webhooks/slack-native-config' const logger = createLogger('SlackAppWebhookAPI') @@ -44,13 +44,7 @@ async function handleSlackAppWebhook(request: NextRequest): Promise // Route by the installed workspace(s). For Slack Connect the outer `team_id` diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/route.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/route.ts index d47add6d93a..647fe51daa1 100644 --- a/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/route.ts @@ -1,5 +1,4 @@ import { - deleteCredentialGroupContract, getCredentialGroupContract, updateCredentialGroupContract, } from '@/lib/api/contracts/credential-groups' @@ -9,7 +8,6 @@ import { internalSessionAuth, } from '@/lib/api/server/routes' import { - deleteCredentialGroupSettings, getCredentialGroupSettings, updateCredentialGroupSettings, } from '@/lib/credential-groups/application/manage-groups' @@ -49,17 +47,3 @@ export const PATCH = defineInternalJsonRoute({ useCase: updateCredentialGroupSettings, present: ({ credentialGroup }) => ({ credentialGroup }), }) - -export const DELETE = defineInternalJsonRoute({ - contract: deleteCredentialGroupContract, - auth: internalSessionAuth, - operation: credentialGroupOperations.delete, - rateLimit, - errorPolicy: createCredentialGroupInternalErrorPolicy('Failed to delete credential group'), - mapInput: ({ params }) => ({ - assertedWorkspaceId: params.id, - credentialGroupId: params.groupId, - }), - useCase: deleteCredentialGroupSettings, - present: () => ({ success: true as const }), -}) diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/slack-managed-users/route.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/slack-managed-users/route.ts index 21a9e594ede..fb02424e456 100644 --- a/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/slack-managed-users/route.ts +++ b/apps/sim/app/api/workspaces/[id]/credential-groups/[groupId]/slack-managed-users/route.ts @@ -33,6 +33,7 @@ export const POST = defineInternalJsonRoute({ slackBotCredentialId: body.slackBotCredentialId, clientId: body.clientId, clientSecret: body.clientSecret, + requiredScopes: body.requiredScopes, }), useCase: startSlackCredentialGroupConfiguration, }) diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/ensure/route.test.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/ensure/route.test.ts new file mode 100644 index 00000000000..4f6d7ba70bb --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/credential-groups/ensure/route.test.ts @@ -0,0 +1,76 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ ensure: vi.fn(), getSession: vi.fn() })) +vi.mock('@/lib/auth', () => ({ getSession: mocks.getSession })) +vi.mock('@/lib/credential-groups/application/manage-groups', () => ({ + ensureWorkspaceAccounts: { + operation: { id: 'credential_groups.workspace.ensure' }, + execute: mocks.ensure, + }, +})) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { POST } from '@/app/api/workspaces/[id]/credential-groups/ensure/route' + +const workspaceId = '11111111-1111-4111-8111-111111111111' +const credentialGroup = { + id: '22222222-2222-4222-8222-222222222222', + workspaceId, + name: 'Connected accounts', + description: null, + options: [], + mcpServers: [], + status: 'active', + createdAt: '2026-09-04T00:00:00.000Z', + updatedAt: '2026-09-04T00:00:00.000Z', +} +const context = { params: Promise.resolve({ id: workspaceId }) } +const request = () => + new NextRequest(`http://localhost:3000/api/workspaces/${workspaceId}/credential-groups/ensure`, { + method: 'POST', + }) + +describe('workspace connected accounts setup route', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getSession.mockResolvedValue({ + user: { id: 'admin-1' }, + session: { id: 'session-1' }, + }) + mocks.ensure.mockResolvedValue({ credentialGroup, created: true }) + }) + + it('authenticates before validating workspace parameters', async () => { + mocks.getSession.mockResolvedValue(null) + const response = await POST(request(), { params: Promise.resolve({ id: '' }) }) + expect(response.status).toBe(401) + expect(mocks.ensure).not.toHaveBeenCalled() + }) + + it.each([true, false])( + 'returns the same account shape when newly created is %s', + async (created) => { + mocks.ensure.mockResolvedValue({ credentialGroup, created }) + const input = request() + const response = await POST(input, context) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ credentialGroup }) + expect(mocks.ensure).toHaveBeenCalledWith({ + principal: { kind: 'session', userId: 'admin-1', sessionId: 'session-1' }, + input: { workspaceId }, + request: input, + }) + } + ) + + it('preserves the application authorization refusal', async () => { + mocks.ensure.mockRejectedValue(new OrchestrationError('forbidden', 'Admin access required')) + const response = await POST(request(), context) + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ error: 'Admin access required' }) + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/ensure/route.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/ensure/route.ts new file mode 100644 index 00000000000..f4bc337bbd2 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/credential-groups/ensure/route.ts @@ -0,0 +1,23 @@ +import { ensureWorkspaceAccountsContract } from '@/lib/api/contracts/credential-groups' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { ensureWorkspaceAccounts } from '@/lib/credential-groups/application/manage-groups' +import { credentialGroupOperations } from '@/lib/credential-groups/application/operations' +import { createCredentialGroupInternalErrorPolicy } from '@/app/api/workspaces/[id]/credential-groups/error-policy' + +export const POST = defineInternalJsonRoute({ + contract: ensureWorkspaceAccountsContract, + auth: internalSessionAuth, + operation: credentialGroupOperations.ensureWorkspaceAccounts, + rateLimit: internalRateLimits.none({ reason: 'Idempotent, admin-only workspace account setup' }), + errorPolicy: createCredentialGroupInternalErrorPolicy( + 'Failed to set up connected accounts', + 'Workspace not found' + ), + mapInput: ({ params }) => ({ workspaceId: params.id }), + useCase: ensureWorkspaceAccounts, + present: ({ credentialGroup }) => ({ credentialGroup }), +}) diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/route.test.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/route.test.ts index 6259951e256..0016c90d4a2 100644 --- a/apps/sim/app/api/workspaces/[id]/credential-groups/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/credential-groups/route.test.ts @@ -6,7 +6,6 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - create: vi.fn(), getSession: vi.fn(), list: vi.fn(), })) @@ -14,30 +13,20 @@ const mocks = vi.hoisted(() => ({ vi.mock('@/lib/auth', () => ({ getSession: mocks.getSession })) vi.mock('@/lib/credential-groups/application/manage-groups', () => ({ - createCredentialGroupSettings: { - operation: { id: 'credential_groups.create' }, - execute: mocks.create, - }, - listCredentialGroupSettings: { - operation: { id: 'credential_groups.settings.list' }, + getWorkspaceAccountsSettings: { + operation: { id: 'credential_groups.workspace.read' }, execute: mocks.list, }, })) import { OrchestrationError } from '@/lib/core/orchestration/types' -import { CredentialGroupProviderConfigurationError } from '@/lib/credential-groups/provider-adapter' -import { GET, POST } from '@/app/api/workspaces/[id]/credential-groups/route' +import { GET } from '@/app/api/workspaces/[id]/credential-groups/route' const WORKSPACE_ID = '11111111-1111-4111-8111-111111111111' const context = { params: Promise.resolve({ id: WORKSPACE_ID }) } -function createRequest(method: 'GET' | 'POST', body?: Record): NextRequest { - return new NextRequest(`http://localhost:3000/api/workspaces/${WORKSPACE_ID}/credential-groups`, { - method, - ...(body - ? { body: JSON.stringify(body), headers: { 'content-type': 'application/json' } } - : {}), - }) +function createRequest(): NextRequest { + return new NextRequest(`http://localhost:3000/api/workspaces/${WORKSPACE_ID}/credential-groups`) } describe('credential groups collection route', () => { @@ -47,24 +36,15 @@ describe('credential groups collection route', () => { user: { id: 'user-1' }, session: { id: 'session-1' }, }) - mocks.list.mockResolvedValue({ credentialGroups: [], availableProviders: ['gmail'] }) - }) - - it('authenticates before parsing the request body', async () => { - mocks.getSession.mockResolvedValue(null) - - const response = await POST(createRequest('POST', {}), context) - - expect(response.status).toBe(401) - expect(mocks.create).not.toHaveBeenCalled() + mocks.list.mockResolvedValue({ credentialGroup: null, availableProviders: ['gmail'] }) }) it('enters the application use case with the authenticated session principal', async () => { - const request = createRequest('GET') + const request = createRequest() const response = await GET(request, context) expect(response.status).toBe(200) - expect(await response.json()).toEqual({ credentialGroups: [], availableProviders: ['gmail'] }) + expect(await response.json()).toEqual({ credentialGroup: null, availableProviders: ['gmail'] }) expect(mocks.list).toHaveBeenCalledWith({ principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, input: { workspaceId: WORKSPACE_ID }, @@ -77,28 +57,9 @@ describe('credential groups collection route', () => { new OrchestrationError('not_found', 'Credential Groups are not available') ) - const response = await GET(createRequest('GET'), context) + const response = await GET(createRequest(), context) expect(response.status).toBe(404) expect(await response.json()).toEqual({ error: 'Credential Groups are not available' }) }) - - it('fails fast when managed Gmail OAuth is not configured', async () => { - mocks.create.mockRejectedValue( - new CredentialGroupProviderConfigurationError('Managed Gmail authorization is not configured') - ) - - const response = await POST( - createRequest('POST', { - name: 'Support inboxes', - options: [{ provider: 'gmail', label: 'Gmail', required: true }], - }), - context - ) - - expect(response.status).toBe(503) - expect(await response.json()).toEqual({ - error: 'Managed Gmail authorization is not configured', - }) - }) }) diff --git a/apps/sim/app/api/workspaces/[id]/credential-groups/route.ts b/apps/sim/app/api/workspaces/[id]/credential-groups/route.ts index c776f985698..27b7c06beea 100644 --- a/apps/sim/app/api/workspaces/[id]/credential-groups/route.ts +++ b/apps/sim/app/api/workspaces/[id]/credential-groups/route.ts @@ -1,45 +1,24 @@ -import { - createCredentialGroupContract, - listCredentialGroupsContract, -} from '@/lib/api/contracts/credential-groups' +import { getWorkspaceAccountsContract } from '@/lib/api/contracts/credential-groups' import { defineInternalJsonRoute, internalRateLimits, internalSessionAuth, } from '@/lib/api/server/routes' -import { - createCredentialGroupSettings, - listCredentialGroupSettings, -} from '@/lib/credential-groups/application/manage-groups' +import { getWorkspaceAccountsSettings } from '@/lib/credential-groups/application/manage-groups' import { credentialGroupOperations } from '@/lib/credential-groups/application/operations' import { createCredentialGroupInternalErrorPolicy } from '@/app/api/workspaces/[id]/credential-groups/error-policy' export const GET = defineInternalJsonRoute({ - contract: listCredentialGroupsContract, + contract: getWorkspaceAccountsContract, auth: internalSessionAuth, - operation: credentialGroupOperations.listSettings, + operation: credentialGroupOperations.workspaceSettings, rateLimit: internalRateLimits.none({ - reason: 'Preserve existing internal Credential Group list behavior', + reason: 'Workspace account settings do not require additional admission limits', }), errorPolicy: createCredentialGroupInternalErrorPolicy( - 'Failed to list credential groups', + 'Failed to load connected accounts', 'Workspace not found' ), mapInput: ({ params }) => ({ workspaceId: params.id }), - useCase: listCredentialGroupSettings, -}) - -export const POST = defineInternalJsonRoute({ - contract: createCredentialGroupContract, - auth: internalSessionAuth, - operation: credentialGroupOperations.create, - rateLimit: internalRateLimits.none({ - reason: 'Preserve existing internal Credential Group create behavior', - }), - errorPolicy: createCredentialGroupInternalErrorPolicy( - 'Failed to create credential group', - 'Workspace not found' - ), - mapInput: ({ params, body }) => ({ workspaceId: params.id, credentialGroup: body }), - useCase: createCredentialGroupSettings, + useCase: getWorkspaceAccountsSettings, }) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/export/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/export/route.test.ts new file mode 100644 index 00000000000..54df98275b7 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/export/route.test.ts @@ -0,0 +1,159 @@ +/** + * @vitest-environment node + */ +import { authMockFns } from '@sim/testing' +import { PASTE_LIMITS } from '@sim/utils/paste' +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ execute: vi.fn(), analytics: vi.fn() })) + +vi.mock('@/lib/workspace-files/application/export-workspace-file-snapshot', () => ({ + exportWorkspaceFileSnapshot: { + operation: { id: 'files.download', minimumRole: 'read', workspaceApiKey: 'allow' }, + execute: mocks.execute, + }, +})) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.analytics })) + +import { NoWorkspaceAccessError } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { POST } from '@/app/api/workspaces/[id]/files/[fileId]/export/route' + +const WORKSPACE_ID = '7727ef3f-8cf6-4686-b063-2bb006a10785' +const FILE_ID = 'wf_document' +const context = { params: Promise.resolve({ id: WORKSPACE_ID, fileId: FILE_ID }) } +const principal = { kind: 'session', userId: 'user-1', sessionId: 'session-1' } + +function request(body: unknown, declaredSize?: number) { + return new NextRequest( + `http://localhost/api/workspaces/${WORKSPACE_ID}/files/${FILE_ID}/export`, + { + method: 'POST', + headers: { + 'content-type': 'application/json', + ...(declaredSize === undefined ? {} : { 'content-length': String(declaredSize) }), + }, + body: JSON.stringify(body), + } + ) +} + +describe('POST workspace Markdown snapshot export', () => { + beforeEach(() => { + vi.clearAllMocks() + authMockFns.mockGetSession.mockResolvedValue({ + user: { id: 'user-1' }, + session: { id: 'session-1' }, + }) + mocks.execute.mockResolvedValue({ + file: { id: FILE_ID, workspaceId: WORKSPACE_ID }, + buffer: Buffer.from('new visible text'), + fileName: 'notes.md', + contentType: 'text/markdown; charset=utf-8', + assetCount: 0, + format: 'markdown', + }) + }) + + it('passes the snapshot to the authorized download operation and returns uncached binary bytes', async () => { + const req = request({ content: 'new visible text' }) + const response = await POST(req, context) + + expect(mocks.execute).toHaveBeenCalledWith({ + principal, + input: { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID, content: 'new visible text' }, + request: req, + }) + expect(response.status).toBe(200) + expect(await response.text()).toBe('new visible text') + expect(response.headers.get('content-type')).toBe('text/markdown; charset=utf-8') + expect(response.headers.get('content-length')).toBe('16') + expect(response.headers.get('content-disposition')).toContain('notes.md') + expect(response.headers.get('cache-control')).toBe('private, no-store') + expect(mocks.analytics).toHaveBeenCalledWith( + 'user-1', + 'file_downloaded', + { workspace_id: WORKSPACE_ID, is_bulk: false, file_count: 1 }, + { groups: { workspace: WORKSPACE_ID } } + ) + }) + + it.each(['', '---\ntitle: 中文 😀\n---\n# Résumé'])( + 'accepts exact empty or Unicode/frontmatter snapshots: %j', + async (content) => { + const response = await POST(request({ content }), context) + expect(response.status).toBe(200) + expect(mocks.execute).toHaveBeenCalledWith( + expect.objectContaining({ input: expect.objectContaining({ content }) }) + ) + } + ) + + it('authenticates before parsing an oversized body', async () => { + authMockFns.mockGetSession.mockResolvedValue(null) + const response = await POST(request({ content: 'x' }, 11 * 1024 * 1024), context) + expect(response.status).toBe(401) + expect(mocks.execute).not.toHaveBeenCalled() + expect(mocks.analytics).not.toHaveBeenCalled() + }) + + it('rejects an oversized declared request before dispatch', async () => { + const response = await POST(request({ content: 'x' }, 11 * 1024 * 1024), context) + expect(response.status).toBe(413) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it.each([{}, { content: null }, { content: 1 }])( + 'rejects invalid snapshot input %j', + async (body) => { + const response = await POST(request(body), context) + expect(response.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + } + ) + + it('enforces UTF-8 rather than UTF-16 snapshot size', async () => { + const content = '😀'.repeat(Math.floor(PASTE_LIMITS.RICH_MARKDOWN_BYTES / 4) + 1) + const response = await POST(request({ content }), context) + expect(response.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('conceals cross-tenant access and emits no download analytics', async () => { + mocks.execute.mockRejectedValue(new NoWorkspaceAccessError()) + const response = await POST(request({ content: 'x' }), context) + expect(response.status).toBe(404) + expect(mocks.analytics).not.toHaveBeenCalled() + }) + + it('renders size rejection without logging a successful download', async () => { + mocks.execute.mockRejectedValue(new OrchestrationError('validation', 'Export limit exceeded')) + const response = await POST(request({ content: 'x' }), context) + expect(response.status).toBe(400) + expect(await response.json()).toMatchObject({ error: 'Export limit exceeded' }) + expect(mocks.analytics).not.toHaveBeenCalled() + }) + + it('preserves the ZIP filename and bundle analytics', async () => { + mocks.execute.mockResolvedValue({ + file: { id: FILE_ID, workspaceId: WORKSPACE_ID }, + buffer: Buffer.from('zip bytes'), + fileName: 'Résumé.zip', + contentType: 'application/zip', + assetCount: 2, + format: 'zip', + }) + const response = await POST(request({ content: 'x' }), context) + expect(response.headers.get('content-type')).toBe('application/zip') + expect(response.headers.get('content-disposition')).toContain( + "filename*=UTF-8''R%C3%A9sum%C3%A9.zip" + ) + expect(mocks.analytics).toHaveBeenCalledWith( + 'user-1', + 'file_downloaded', + { workspace_id: WORKSPACE_ID, is_bulk: true, file_count: 3 }, + { groups: { workspace: WORKSPACE_ID } } + ) + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/export/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/export/route.ts new file mode 100644 index 00000000000..c6d573e4d5c --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/export/route.ts @@ -0,0 +1,43 @@ +import { exportWorkspaceFileSnapshotContract } from '@/lib/api/contracts/workspace-files' +import { + defineInternalBinaryRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { captureServerEvent } from '@/lib/posthog/server' +import { internalFileErrorPolicies } from '@/lib/workspace-files/api' +import { exportWorkspaceFileSnapshot } from '@/lib/workspace-files/application/export-workspace-file-snapshot' +import { encodeFilenameForHeader } from '@/app/api/files/utils' + +export const POST = defineInternalBinaryRoute({ + contract: exportWorkspaceFileSnapshotContract, + auth: internalSessionAuth, + operation: exportWorkspaceFileSnapshot.operation, + rateLimit: internalRateLimits.none({ reason: 'Preserve internal file download behavior' }), + errorPolicy: internalFileErrorPolicies.concealResourceAuthorization, + mapInput: ({ params, body }) => ({ + fileId: params.fileId, + assertedWorkspaceId: params.id, + content: body.content, + }), + useCase: exportWorkspaceFileSnapshot, + onSuccess: ({ principal, result }) => { + captureServerEvent( + principal.userId, + 'file_downloaded', + { + workspace_id: result.file.workspaceId, + is_bulk: result.assetCount > 0, + file_count: 1 + result.assetCount, + }, + { groups: { workspace: result.file.workspaceId } } + ) + }, + present: ({ buffer, fileName, contentType }) => ({ + body: new Uint8Array(buffer.buffer as ArrayBuffer, buffer.byteOffset, buffer.byteLength), + contentType, + contentLength: buffer.length, + contentDisposition: `attachment; ${encodeFilenameForHeader(fileName)}`, + headers: { 'Cache-Control': 'private, no-store' }, + }), +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/download/route.ts b/apps/sim/app/api/workspaces/[id]/files/download/route.ts index 6d4220fd1a2..f1ccd1ce2bd 100644 --- a/apps/sim/app/api/workspaces/[id]/files/download/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/download/route.ts @@ -46,6 +46,7 @@ export const GET = defineInternalBinaryRoute({ filesToZip.map((file) => ({ name: file.name, folderPath: file.folderId ? folderPaths.get(file.folderId) : null, + contentType: file.type, })) ) const archive = new ZipArchive({ store: true }) diff --git a/apps/sim/app/api/workspaces/[id]/organization-accounts/route.ts b/apps/sim/app/api/workspaces/[id]/organization-accounts/route.ts new file mode 100644 index 00000000000..16ec78bb6c8 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/organization-accounts/route.ts @@ -0,0 +1,18 @@ +import { getWorkspaceOrganizationAccountsContract } from '@/lib/api/contracts/organization-accounts' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { getWorkspaceOrganizationAccounts } from '@/lib/credential-groups/application/workspace-organization-accounts' + +export const GET = defineInternalJsonRoute({ + contract: getWorkspaceOrganizationAccountsContract, + auth: internalSessionAuth, + operation: getWorkspaceOrganizationAccounts.operation, + rateLimit: internalRateLimits.none({ reason: 'Read-only workspace organization account status' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ workspaceId: params.id }), + useCase: getWorkspaceOrganizationAccounts, +}) diff --git a/apps/sim/app/api/workspaces/[id]/permissions/route.test.ts b/apps/sim/app/api/workspaces/[id]/permissions/route.test.ts index 36683982480..4e58f47cc1d 100644 --- a/apps/sim/app/api/workspaces/[id]/permissions/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/permissions/route.test.ts @@ -16,9 +16,18 @@ import { } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockSyncWorkspaceEnvCredentials, mockGetEffectiveWorkspacePermission } = vi.hoisted(() => ({ +const { + mockSyncWorkspaceEnvCredentials, + mockGetEffectiveWorkspacePermission, + mockAssertMembershipNotScimManaged, +} = vi.hoisted(() => ({ mockSyncWorkspaceEnvCredentials: vi.fn(), mockGetEffectiveWorkspacePermission: vi.fn(), + mockAssertMembershipNotScimManaged: vi.fn(), +})) + +vi.mock('@/ee/scim/lib/managed-membership', () => ({ + assertMembershipNotScimManaged: mockAssertMembershipNotScimManaged, })) vi.mock('@sim/audit', () => auditMock) @@ -37,6 +46,7 @@ vi.mock('@/lib/workspaces/permissions/utils', () => ({ getEffectiveWorkspacePermission: mockGetEffectiveWorkspacePermission, })) +import { ForbiddenOperationError } from '@/lib/core/application' import { PATCH } from '@/app/api/workspaces/[id]/permissions/route' const mockGetSession = authMockFns.mockGetSession @@ -558,6 +568,37 @@ describe('workspace permissions route', () => { }) }) + it('refuses a role change for a member the directory manages', async () => { + queueOrgWorkspace([], [permissionRow(MEMBER_ID, 'read')]) + mockAssertMembershipNotScimManaged.mockRejectedValueOnce( + new ForbiddenOperationError('SCIM_MANAGED_MEMBERSHIP', 'Managed by the directory') + ) + + const response = await PATCH( + createMockRequest('PATCH', { updates: [{ userId: MEMBER_ID, permissions: 'write' }] }), + routeContext + ) + + expect(response.status).toBe(403) + await expect(response.json()).resolves.toMatchObject({ error: 'Managed by the directory' }) + expect(mockAssertMembershipNotScimManaged).toHaveBeenCalledWith( + expect.objectContaining({ organizationId: ORG_ID, userId: MEMBER_ID }) + ) + expect(dbChainMockFns.update).not.toHaveBeenCalled() + }) + + it('does not consult the directory for a personal workspace', async () => { + queuePersonalWorkspace([permissionRow(ADMIN_ID, 'admin'), permissionRow(MEMBER_ID, 'read')]) + dbChainMockFns.returning.mockResolvedValue([{ id: 'perm-1' }]) + + await PATCH( + createMockRequest('PATCH', { updates: [{ userId: MEMBER_ID, permissions: 'write' }] }), + routeContext + ) + + expect(mockAssertMembershipNotScimManaged).not.toHaveBeenCalled() + }) + it('refuses to change the role of an organization admin', async () => { queueOrgWorkspace([{ userId: MEMBER_ID }], [permissionRow(MEMBER_ID, 'admin')]) diff --git a/apps/sim/app/api/workspaces/[id]/permissions/route.ts b/apps/sim/app/api/workspaces/[id]/permissions/route.ts index 4d5809de87d..05fe91930fc 100644 --- a/apps/sim/app/api/workspaces/[id]/permissions/route.ts +++ b/apps/sim/app/api/workspaces/[id]/permissions/route.ts @@ -12,6 +12,7 @@ import { } from '@/lib/api/contracts/workspaces' import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' import { getSession } from '@/lib/auth' +import { ForbiddenOperationError } from '@/lib/core/application' import { HttpError } from '@/lib/core/utils/http-error' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { syncWorkspaceEnvCredentials } from '@/lib/credentials/environment' @@ -24,6 +25,7 @@ import { getWorkspaceWithOwner, hasWorkspaceAdminAccess, } from '@/lib/workspaces/permissions/utils' +import { assertMembershipNotScimManaged } from '@/ee/scim/lib/managed-membership' const logger = createLogger('WorkspacesPermissionsAPI') @@ -92,6 +94,16 @@ class WorkspaceBusyError extends HttpError { } } +/** A target whose membership the organization's directory owns; the guard's own wording names the remedy. */ +class DirectoryManagedMemberError extends HttpError { + readonly statusCode = 403 + + constructor(cause: ForbiddenOperationError) { + super(cause.message) + this.name = 'DirectoryManagedMemberError' + } +} + /** * Bounds the wait on the row locks below so a stuck holder fails fast * (SQLSTATE 55P03) instead of parking a pooled connection indefinitely. @@ -376,6 +388,15 @@ export const PATCH = withRouteHandler( orgAdminUserIds = new Set( lockedMembers.filter((row) => isOrgAdminRole(row.role)).map((row) => row.userId) ) + /** + * A directory that owns membership also owns the workspace role it set, + * so the change is refused here for the same reason an invitation is: + * the next sync would revert it. Checked under the member lock so a + * connection enabled mid-request cannot slip a change past it. + */ + for (const userId of targetUserIds) { + await assertMembershipNotScimManaged({ organizationId, userId, executor: tx }) + } } /** @@ -526,6 +547,7 @@ export const PATCH = withRouteHandler( }) throw new WorkspaceBusyError() } + if (error instanceof ForbiddenOperationError) throw new DirectoryManagedMemberError(error) throw error }) diff --git a/apps/sim/app/api/workspaces/[id]/route.ts b/apps/sim/app/api/workspaces/[id]/route.ts index 5e261f4b326..7fce432cca5 100644 --- a/apps/sim/app/api/workspaces/[id]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/route.ts @@ -78,11 +78,10 @@ export const PATCH = withRouteHandler( try { const body = parsed.data.body - const { name, color, logoUrl, billedAccountUserId, allowPersonalApiKeys } = body + const { name, logoUrl, billedAccountUserId, allowPersonalApiKeys } = body if ( name === undefined && - color === undefined && logoUrl === undefined && billedAccountUserId === undefined && allowPersonalApiKeys === undefined @@ -106,10 +105,6 @@ export const PATCH = withRouteHandler( updateData.name = name } - if (color !== undefined) { - updateData.color = color - } - if (logoUrl !== undefined) { updateData.logoUrl = logoUrl } @@ -198,7 +193,6 @@ export const PATCH = withRouteHandler( metadata: { changes: { ...(name !== undefined && { name: { from: existingWorkspace.name, to: name } }), - ...(color !== undefined && { color: { from: existingWorkspace.color, to: color } }), ...(logoUrl !== undefined && { logoUrl: { from: existingWorkspace.logoUrl, to: logoUrl }, }), diff --git a/apps/sim/app/api/workspaces/invitations/batch/route.test.ts b/apps/sim/app/api/workspaces/invitations/batch/route.test.ts new file mode 100644 index 00000000000..21a928e499f --- /dev/null +++ b/apps/sim/app/api/workspaces/invitations/batch/route.test.ts @@ -0,0 +1,94 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ session: vi.fn(), execute: vi.fn() })) +vi.mock('@/lib/auth', () => ({ getSession: mocks.session })) +vi.mock('@/lib/invitations/application/send-invitation-batch', () => { + const operation = { + id: 'invitations.send_batch', + capability: 'invitations.send', + principalKinds: ['session'], + } + return { + invitationOperations: { sendBatch: operation }, + sendInvitationBatch: { operation, execute: mocks.execute }, + } +}) + +import { WorkspaceInvitationError } from '@/lib/invitations/workspace-invitations' +import { POST } from '@/app/api/workspaces/invitations/batch/route' + +function request(body: unknown) { + return createMockRequest( + 'POST', + body, + undefined, + 'http://localhost:3000/api/workspaces/invitations/batch' + ) +} + +beforeEach(() => { + vi.clearAllMocks() + mocks.session.mockResolvedValue({ user: { id: 'actor' }, session: { id: 'session' } }) + mocks.execute.mockResolvedValue({ + success: true, + successful: ['person@example.com'], + added: [], + failed: [], + invitations: [], + }) +}) + +describe('invitation batch route', () => { + it('authenticates before parsing or calling the operation', async () => { + mocks.session.mockResolvedValue(null) + const response = await POST(request({}), { params: Promise.resolve({}) }) + expect(response.status).toBe(401) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('forwards a canonical session principal and explicit organization-only input', async () => { + const body = { + organizationId: 'org-target', + workspaceIds: [], + emails: ['person@example.com'], + membership: 'member', + } + const response = await POST(request(body), { params: Promise.resolve({}) }) + expect(response.status).toBe(200) + expect(mocks.execute).toHaveBeenCalledWith( + expect.objectContaining({ + principal: { kind: 'session', sessionId: 'session', userId: 'actor' }, + input: body, + }) + ) + }) + + it('rejects empty unscoped workspace lists before use-case execution', async () => { + const response = await POST(request({ workspaceIds: [], emails: ['person@example.com'] }), { + params: Promise.resolve({}), + }) + expect(response.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('preserves authorization refusal status and error shape', async () => { + mocks.execute.mockRejectedValue( + new WorkspaceInvitationError({ + message: 'Only organization owners and admins can invite members.', + status: 403, + }) + ) + const response = await POST( + request({ organizationId: 'org', workspaceIds: [], emails: ['person@example.com'] }), + { params: Promise.resolve({}) } + ) + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ + error: 'Only organization owners and admins can invite members.', + }) + }) +}) diff --git a/apps/sim/app/api/workspaces/invitations/batch/route.ts b/apps/sim/app/api/workspaces/invitations/batch/route.ts index f41989b9c8e..905fb499ae4 100644 --- a/apps/sim/app/api/workspaces/invitations/batch/route.ts +++ b/apps/sim/app/api/workspaces/invitations/batch/route.ts @@ -1,129 +1,43 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { normalizeEmail } from '@sim/utils/string' -import { type NextRequest, NextResponse } from 'next/server' import { batchWorkspaceInvitationsContract } from '@/lib/api/contracts/invitations' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { - createWorkspaceInvitation, - prepareWorkspaceInvitationContext, - WorkspaceInvitationError, - type WorkspaceInvitationResult, -} from '@/lib/invitations/workspace-invitations' + defineInternalJsonRoute, + internalErrorResponse, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + invitationOperations, + sendInvitationBatch, +} from '@/lib/invitations/application/send-invitation-batch' +import { WorkspaceInvitationError } from '@/lib/invitations/workspace-invitations' import { InvitationsNotAllowedError } from '@/ee/access-control/utils/permission-check' export const dynamic = 'force-dynamic' -const logger = createLogger('WorkspaceInvitationBatchAPI') - -interface BatchInvitationFailure { - email: string - error: string -} - -function batchErrorResponse(error: unknown) { - if (error instanceof WorkspaceInvitationError) { - return NextResponse.json( - { - error: error.message, - ...(error.email ? { email: error.email } : {}), - ...(error.upgradeRequired !== undefined ? { upgradeRequired: error.upgradeRequired } : {}), - }, - { status: error.status } - ) - } - - if (error instanceof InvitationsNotAllowedError) { - return NextResponse.json({ error: error.message }, { status: 403 }) - } - - logger.error('Error creating workspace invitation batch:', error) - return NextResponse.json({ error: 'Failed to create invitation batch' }, { status: 500 }) -} - -export const POST = withRouteHandler(async (req: NextRequest) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - try { - const parsed = await parseRequest(batchWorkspaceInvitationsContract, req, {}) - if (!parsed.success) return parsed.response - const { body } = parsed.data - - const context = await prepareWorkspaceInvitationContext({ - workspaceIds: body.workspaceIds, - inviterId: session.user.id, - inviterName: session.user.name || session.user.email || 'A user', - inviterEmail: session.user.email, - }) - - const successful: string[] = [] - const added: string[] = [] - const failed: BatchInvitationFailure[] = [] - const invitations: WorkspaceInvitationResult[] = [] - const seenEmails = new Set() - - for (const rawEmail of body.emails) { - const normalizedEmail = normalizeEmail(rawEmail) - if (seenEmails.has(normalizedEmail)) { - failed.push({ - email: normalizedEmail, - error: `${normalizedEmail} appears more than once in this invitation batch`, - }) - continue - } - seenEmails.add(normalizedEmail) - - try { - const invitation = await createWorkspaceInvitation({ - context, - email: rawEmail, - permission: body.permission, - membership: body.membership, - request: req, - }) - if (invitation.instantAdd) { - // Only report an actual insertion; an `unchanged` outcome means the - // user already had access (rare race) and is a silent no-op. - if (invitation.outcome === 'added') added.push(invitation.email) - } else { - successful.push(invitation.email) - } - invitations.push(invitation) - } catch (error) { - if (error instanceof WorkspaceInvitationError) { - failed.push({ email: error.email ?? normalizedEmail, error: error.message }) - continue - } - - /** - * One bad address must not discard the invitations that already - * succeeded, so unexpected failures are reported per email rather than - * aborting the batch. - */ - logger.error('Unexpected workspace invitation batch item failure:', { - email: normalizedEmail, - error, - }) - failed.push({ - email: normalizedEmail, - error: getErrorMessage(error, 'Failed to create invitation'), +export const POST = defineInternalJsonRoute({ + contract: batchWorkspaceInvitationsContract, + auth: internalSessionAuth, + operation: invitationOperations.sendBatch, + rateLimit: internalRateLimits.none({ + reason: 'Preserve the existing bounded invitation batch behavior.', + }), + errorPolicy: { + project(error) { + if (error instanceof WorkspaceInvitationError) { + return internalErrorResponse(error.status, { + error: error.message, + ...(error.email ? { email: error.email } : {}), + ...(error.upgradeRequired !== undefined + ? { upgradeRequired: error.upgradeRequired } + : {}), }) } - } - - return NextResponse.json({ - success: failed.length === 0, - successful, - added, - failed, - invitations, - }) - } catch (error) { - return batchErrorResponse(error) - } + if (error instanceof InvitationsNotAllowedError) + return internalErrorResponse(403, { error: error.message }) + return null + }, + unhandled: () => internalErrorResponse(500, { error: 'Failed to create invitation batch' }), + }, + mapInput: ({ body }) => body, + useCase: sendInvitationBatch, }) diff --git a/apps/sim/app/api/workspaces/invitations/route.test.ts b/apps/sim/app/api/workspaces/invitations/route.test.ts index 8460eba4129..2aa469b8ffa 100644 --- a/apps/sim/app/api/workspaces/invitations/route.test.ts +++ b/apps/sim/app/api/workspaces/invitations/route.test.ts @@ -119,7 +119,9 @@ describe('POST /api/workspaces/invitations/batch', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() + queueTableRows(schemaMock.user, [{ id: 'user-1', name: 'Owner User', email: 'owner@test.com' }]) mockGetSession.mockResolvedValue({ + session: { id: 'session-1' }, user: { id: 'user-1', email: 'owner@test.com', name: 'Owner User' }, }) mockGetWorkspaceWithOwner.mockResolvedValue({ @@ -171,6 +173,29 @@ describe('POST /api/workspaces/invitations/batch', () => { afterAll(() => { resetDbChainMock() + queueTableRows(schemaMock.user, [{ id: 'user-1', name: 'Owner User', email: 'owner@test.com' }]) + }) + + it('keeps unexpected database details out of per-email failures', async () => { + mockCreatePendingInvitation.mockRejectedValueOnce( + new Error('Failed query: select "id" from "user"; params: private-data') + ) + + const response = await POST( + createMockRequest('POST', { + workspaceIds: ['workspace-1'], + emails: ['new@example.com'], + permission: 'read', + }) + ) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.success).toBe(false) + expect(data.failed).toEqual([ + { email: 'new@example.com', error: 'Failed to create invitation. Please try again.' }, + ]) + expect(mockSendInvitationEmail).not.toHaveBeenCalled() }) it('blocks invites for personal workspaces with an upgrade prompt', async () => { diff --git a/apps/sim/app/api/workspaces/members/[id]/route.ts b/apps/sim/app/api/workspaces/members/[id]/route.ts index 1a028d91813..64d47166fcb 100644 --- a/apps/sim/app/api/workspaces/members/[id]/route.ts +++ b/apps/sim/app/api/workspaces/members/[id]/route.ts @@ -10,18 +10,13 @@ import { getSession } from '@/lib/auth' import { removeUserFromOrganization } from '@/lib/billing/organizations/membership' import { reconcileOrganizationSeats } from '@/lib/billing/organizations/seats' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { revokeWorkspaceCredentialMembershipsTx } from '@/lib/credentials/access' import { captureServerEvent } from '@/lib/posthog/server' -import { removeWorkspaceSkillMembershipsTx } from '@/lib/skills/access' +import { revokeWorkspaceAccessTx } from '@/lib/workspaces/access/workspace-access' import { hasWorkspaceAdminAccess, isOrganizationAdminOrOwner, } from '@/lib/workspaces/permissions/utils' -import { - reassignWorkflowOwnershipForWorkspaceMemberRemovalTx, - transferWorkspaceOwnershipToBilledAccountForMemberRemovalTx, - WorkspaceBillingAccountRemovalError, -} from '@/lib/workspaces/utils' +import { WorkspaceBillingAccountRemovalError } from '@/lib/workspaces/utils' const logger = createLogger('WorkspaceMemberAPI') @@ -147,41 +142,11 @@ export const DELETE = withRouteHandler( } } - const { ownershipTransferred, workflowOwnershipReassignment } = await db.transaction( - async (tx) => { - const didTransferOwnership = - await transferWorkspaceOwnershipToBilledAccountForMemberRemovalTx({ - tx, - workspaceId, - departingUserId: userId, - }) - - const workflowOwnershipReassignment = - await reassignWorkflowOwnershipForWorkspaceMemberRemovalTx({ - tx, - workspaceIds: [workspaceId], - departingUserId: userId, - }) - if (workflowOwnershipReassignment.unresolved.length > 0) { - throw new WorkspaceBillingAccountRemovalError() - } - - await tx - .delete(permissions) - .where( - and( - eq(permissions.userId, userId), - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workspaceId) - ) - ) - - await revokeWorkspaceCredentialMembershipsTx(tx, workspaceId, userId) - await removeWorkspaceSkillMembershipsTx(tx, workspaceId, userId) - - return { ownershipTransferred: didTransferOwnership, workflowOwnershipReassignment } - } + const revocation = await db.transaction((tx) => + revokeWorkspaceAccessTx(tx, { workspaceId, userId }) ) + if (!revocation.revoked) throw new WorkspaceBillingAccountRemovalError() + const { ownershipTransferred } = revocation /** * Seats are tied to organization membership (one per member), so a @@ -214,6 +179,8 @@ export const DELETE = withRouteHandler( organizationId, memberId: orgMembership.id, requireNoOrgWorkspaceAccess: true, + /** Leaving a workspace must not sign the leaver out of Sim. */ + spareSessionToken: session.session.token, }) if (removal.success && removal.removed) { @@ -270,7 +237,6 @@ export const DELETE = withRouteHandler( removedUserRole: userPermission?.permissionType ?? 'owner', selfRemoval: isSelf, ownershipTransferred, - workflowOwnershipReassignment, organizationRemoval, seatReduction, }, diff --git a/apps/sim/app/api/workspaces/route.ts b/apps/sim/app/api/workspaces/route.ts index 50033c1d51c..c5b398fa3f1 100644 --- a/apps/sim/app/api/workspaces/route.ts +++ b/apps/sim/app/api/workspaces/route.ts @@ -123,7 +123,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => { try { const parsed = await parseRequest(createWorkspaceContract, req, {}) if (!parsed.success) return parsed.response - const { name, color, skipDefaultWorkflow } = parsed.data.body + const { name, skipDefaultWorkflow } = parsed.data.body const activeOrganizationId = getActiveOrganizationId(session) const creationPolicy = await getWorkspaceCreationPolicy({ userId: session.user.id, @@ -153,7 +153,6 @@ export const POST = withRouteHandler(async (req: NextRequest) => { userId: session.user.id, name, skipDefaultWorkflow, - explicitColor: color, organizationId: creationPolicy.organizationId, workspaceMode: creationPolicy.workspaceMode, billedAccountUserId: creationPolicy.billedAccountUserId, @@ -188,7 +187,6 @@ export const POST = withRouteHandler(async (req: NextRequest) => { description: `Created workspace "${newWorkspace.name}"`, metadata: { name: newWorkspace.name, - color: newWorkspace.color, workspaceMode: newWorkspace.workspaceMode, organizationId: newWorkspace.organizationId, }, diff --git a/apps/sim/app/cli/auth/done/cli-auth-done-view.tsx b/apps/sim/app/cli/auth/done/cli-auth-done-view.tsx index 31a9610ccbe..ea6116ebd66 100644 --- a/apps/sim/app/cli/auth/done/cli-auth-done-view.tsx +++ b/apps/sim/app/cli/auth/done/cli-auth-done-view.tsx @@ -1,15 +1,19 @@ import { AuthHeader } from '@/app/(auth)/components' +import type { CliAuthDoneStatus } from '@/app/cli/auth/done/search-params' -/** - * Where the CLI's listener sends the browser once it has the authorization - * code. Static by design: the key is minted server-side during the CLI's - * exchange and never passes through this page. - */ -export function CliAuthDoneView() { +interface CliAuthDoneViewProps { + status: CliAuthDoneStatus +} + +export function CliAuthDoneView({ status }: CliAuthDoneViewProps) { return ( ) } diff --git a/apps/sim/app/cli/auth/done/page.tsx b/apps/sim/app/cli/auth/done/page.tsx index db3772dad3d..23161f24f81 100644 --- a/apps/sim/app/cli/auth/done/page.tsx +++ b/apps/sim/app/cli/auth/done/page.tsx @@ -1,21 +1,28 @@ import type { Metadata } from 'next' +import type { SearchParams } from 'nuqs/server' import { AuthShell } from '@/app/(auth)/components' import { CliAuthDoneView } from '@/app/cli/auth/done/cli-auth-done-view' +import { cliAuthDoneSearchParamsCache } from '@/app/cli/auth/done/search-params' export const metadata: Metadata = { - title: 'Terminal connected', + title: 'Terminal sign-in', robots: { index: false, follow: false }, } /** - * The CLI's loopback listener redirects here, so the flow ends on Sim's own - * chrome instead of a page served by the wizard. Public and sessionless on - * purpose — it renders a static confirmation and never touches the API. + * Sessionless completion for OAuth and pairing flows. The status is informational + * and never exchanges credentials or changes authorization. */ -export default function CliAuthDonePage() { +export default async function CliAuthDonePage({ + searchParams, +}: { + searchParams: Promise +}) { + const { status } = await cliAuthDoneSearchParamsCache.parse(searchParams) + return ( - + ) } diff --git a/apps/sim/app/cli/auth/done/search-params.ts b/apps/sim/app/cli/auth/done/search-params.ts new file mode 100644 index 00000000000..f44e205c364 --- /dev/null +++ b/apps/sim/app/cli/auth/done/search-params.ts @@ -0,0 +1,10 @@ +import { createSearchParamsCache, parseAsStringLiteral } from 'nuqs/server' + +const CLI_AUTH_DONE_STATUSES = ['approved', 'cancelled'] as const + +export type CliAuthDoneStatus = (typeof CLI_AUTH_DONE_STATUSES)[number] + +/** Informational only; an omitted status preserves existing pairing callbacks. */ +export const cliAuthDoneSearchParamsCache = createSearchParamsCache({ + status: parseAsStringLiteral(CLI_AUTH_DONE_STATUSES).withDefault('approved'), +}) diff --git a/apps/sim/app/credential-groups/complete/page.tsx b/apps/sim/app/credential-groups/complete/page.tsx index 4841914b303..6191d134b6c 100644 --- a/apps/sim/app/credential-groups/complete/page.tsx +++ b/apps/sim/app/credential-groups/complete/page.tsx @@ -6,12 +6,31 @@ export const metadata: Metadata = { robots: { index: false, follow: false }, } -export default function CredentialGroupCompletePage() { +const OAUTH_FAILURE_MESSAGES = { + denied: 'Authorization was canceled. Return to the chat to try again.', + account_mismatch: 'Choose the account matching your Sim email address.', + permissions_required: 'All requested permissions are required to connect this account.', + configuration_changed: 'The connection settings changed. Return to the chat to try again.', + rate_limited: 'Too many authorization attempts. Wait a few minutes and try again.', + unavailable: 'This connection is unavailable. Return to the chat to try again.', + failed: 'Account authorization did not complete. Return to the chat to try again.', +} as const + +export default async function CredentialGroupCompletePage({ + searchParams, +}: { + searchParams: Promise<{ oauth?: string | string[] }> +}) { + const { oauth } = await searchParams + const error = + typeof oauth === 'string' && Object.hasOwn(OAUTH_FAILURE_MESSAGES, oauth) + ? OAUTH_FAILURE_MESSAGES[oauth as keyof typeof OAUTH_FAILURE_MESSAGES] + : undefined return ( ) diff --git a/apps/sim/app/credential-groups/enroll/[token]/oauth-reconnect-link.tsx b/apps/sim/app/credential-groups/enroll/[token]/oauth-reconnect-link.tsx index ab2fcffbfba..24c77bee3cb 100644 --- a/apps/sim/app/credential-groups/enroll/[token]/oauth-reconnect-link.tsx +++ b/apps/sim/app/credential-groups/enroll/[token]/oauth-reconnect-link.tsx @@ -1,15 +1,15 @@ 'use client' -import { chipVariants } from '@sim/emcn' +import { type ChipLinkProps, chipVariants } from '@sim/emcn' -interface OAuthConnectLinkProps { +interface OAuthConnectLinkProps extends Pick { href: string reconnect?: boolean } -export function OAuthConnectLink({ href, reconnect = false }: OAuthConnectLinkProps) { +export function OAuthConnectLink({ href, reconnect = false, variant }: OAuthConnectLinkProps) { return ( - + {reconnect ? 'Reconnect' : 'Connect'} ) diff --git a/apps/sim/app/credential-groups/enroll/[token]/page.test.tsx b/apps/sim/app/credential-groups/enroll/[token]/page.test.tsx new file mode 100644 index 00000000000..7b5d08d9439 --- /dev/null +++ b/apps/sim/app/credential-groups/enroll/[token]/page.test.tsx @@ -0,0 +1,257 @@ +/** @vitest-environment jsdom */ +import type { ReactNode } from 'react' +import { authMockFns } from '@sim/testing' +import { renderToStaticMarkup } from 'react-dom/server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { PublicCredentialGroupEnrollment } from '@/lib/credential-groups/enrollments' + +const mocks = vi.hoisted(() => ({ + authenticate: vi.fn(), + read: vi.fn(), + rateLimit: vi.fn(), +})) + +vi.mock('next/headers', () => ({ headers: async () => new Headers() })) +vi.mock('@/lib/credential-groups/application/enrollment-auth', () => ({ + authenticateCredentialGroupEnrollment: mocks.authenticate, +})) +vi.mock('@/lib/credential-groups/application/public-enrollment', () => ({ + readPublicCredentialGroupEnrollment: { execute: mocks.read }, +})) +vi.mock('@/lib/credential-groups/rate-limit', () => ({ + enforcePublicCredentialGroupIpRateLimit: mocks.rateLimit, +})) +vi.mock('@/lib/credential-groups/providers', () => ({ + CREDENTIAL_GROUP_PROVIDER_IDS: ['confluence', 'slack'], + CREDENTIAL_GROUP_STANDARD_OAUTH_PROVIDER_IDS: ['confluence'], + getCredentialGroupProviderService: (provider: string) => ({ + providerId: provider, + name: provider === 'confluence' ? 'Confluence' : 'Slack', + icon: () => null, + }), +})) +vi.mock('@/lib/credential-groups/managed-mcp-connector-icons', () => ({ + getManagedMcpConnectorIcon: () => () => null, +})) +vi.mock('@/app/(auth)/components', () => ({ + AuthHeader: ({ title, description }: { title: string; description: string }) => ( +
+

{title}

+

{description}

+
+ ), + SupportFooter: () => null, +})) +vi.mock('@/app/(landing)/components', () => ({ + LogoShell: ({ children }: { children: ReactNode }) =>
{children}
, +})) +vi.mock('@/app/credential-groups/enroll/[token]/oauth-toast', () => ({ + CredentialGroupOAuthToast: ({ message }: { message: string }) => ( +
{message}
+ ), +})) + +import { CredentialGroupProviderConfigurationError } from '@/lib/credential-groups/provider-adapter' +import CredentialGroupEnrollmentPage from '@/app/credential-groups/enroll/[token]/page' + +const principal = { + kind: 'credential_group_enrollment', + workspaceId: 'canonical-workspace', + credentialGroupId: 'accounts', + enrollmentId: 'enrollment', + email: 'member@example.test', + invitationTokenHash: 'hash', +} as const +let enrollment: PublicCredentialGroupEnrollment + +async function render(searchParams: Record = {}) { + const page = await CredentialGroupEnrollmentPage({ + params: Promise.resolve({ token: 'invitation' }), + searchParams: Promise.resolve(searchParams), + }) + document.body.innerHTML = renderToStaticMarkup(page) +} + +function oauthLinks() { + return Array.from(document.querySelectorAll('a')).filter((link) => + link.getAttribute('href')?.includes('/oauth/') + ) +} + +beforeEach(() => { + vi.clearAllMocks() + authMockFns.mockGetSession.mockResolvedValue({ + user: { id: 'member', email: 'member@example.test', emailVerified: true }, + session: { id: 'session-1' }, + }) + mocks.authenticate.mockResolvedValue(principal) + mocks.rateLimit.mockResolvedValue(null) + enrollment = { + inviterName: 'Admin', + workspaceName: 'Company', + credentialGroupName: 'Accounts', + status: 'in_progress', + options: [ + { + id: 'site-one', + label: 'First Confluence site', + provider: 'confluence', + status: 'active', + required: false, + connections: [], + }, + { + id: 'site-two', + label: 'Second Confluence site', + provider: 'confluence', + status: 'active', + required: false, + connections: [], + }, + { + id: 'slack', + label: 'Slack', + provider: 'slack', + status: 'active', + required: true, + connections: [], + }, + ], + mcpServers: [ + { + id: 'mcp-one', + name: 'Unrelated MCP', + description: null, + managedConnectorId: 'linear', + connection: null, + }, + ], + } + mocks.read.mockImplementation(async () => ({ enrollment })) +}) + +afterEach(() => { + document.body.innerHTML = '' +}) + +describe('focused Search enrollment', () => { + it('retains the generic invitation choices and Submit without Search context', async () => { + await render() + expect(oauthLinks()).toHaveLength(3) + expect(document.body.textContent).toContain('Unrelated MCP') + expect(document.querySelector('form')?.getAttribute('action')).toBe( + '/api/credential-groups/enroll/invitation/complete' + ) + expect(document.querySelector('button')?.textContent).toBe('Submit') + expect(document.body.textContent).not.toContain('Return to Search') + expect(document.body.textContent).not.toContain('Setup guide') + expect(mocks.read).toHaveBeenCalledWith({ principal, input: {} }) + }) + + it('shows only the exact requested option and derives the return workspace from the principal', async () => { + await render({ returnTo: 'search', optionId: 'site-two', workspaceId: 'other-workspace' }) + expect(document.querySelector('h1')?.textContent).toBe('Connect your Confluence account') + expect(document.body.textContent).toContain('Second Confluence site') + expect(document.body.textContent).not.toContain('First Confluence site') + expect(document.body.textContent).not.toContain('Slack') + expect(document.body.textContent).not.toContain('Unrelated MCP') + expect(oauthLinks().map((link) => link.getAttribute('href'))).toEqual([ + '/api/credential-groups/enroll/invitation/oauth/site-two?returnTo=search', + ]) + expect(document.querySelector('form')).toBeNull() + expect( + Array.from(document.querySelectorAll('a')) + .find((link) => link.textContent === 'Return to Search') + ?.getAttribute('href') + ).toBe('/workspace/canonical-workspace/search') + const guide = Array.from(document.querySelectorAll('a')).find( + (link) => link.textContent === 'Setup guide' + ) + expect(guide?.getAttribute('href')).toBe('https://docs.sim.ai/search/confluence') + expect(guide?.getAttribute('target')).toBe('_blank') + expect(guide?.getAttribute('rel')).toBe('noopener noreferrer') + expect(mocks.read).toHaveBeenCalledWith({ principal, input: { optionId: 'site-two' } }) + }) + + it.each(['missing', '', 'site-two', ['site-one', 'site-two']])( + 'does not substitute a different account when focus is unusable: %s', + async (optionId) => { + enrollment.options[1]!.status = 'disabled' + await render({ + returnTo: 'search', + optionId: Array.isArray(optionId) ? [...optionId] : optionId, + }) + expect(document.body.textContent).toContain('Ask a workspace admin') + expect(oauthLinks()).toHaveLength(0) + expect(document.querySelector('form')).toBeNull() + expect(document.body.textContent).toContain('Return to Search') + } + ) + + it('reports provider configuration failures with a clear path back to Search', async () => { + mocks.read.mockRejectedValue( + new CredentialGroupProviderConfigurationError('Slack configuration missing') + ) + await render({ returnTo: 'search', optionId: 'slack' }) + expect(document.body.textContent).toContain('Connection unavailable') + expect(document.body.textContent).toContain('Ask a workspace admin') + expect(document.querySelector('a')?.getAttribute('href')).toBe( + '/workspace/canonical-workspace/search' + ) + }) + + it('shows Connected from current credential state without requiring generic completion', async () => { + enrollment.options[1]!.connections = [ + { + email: principal.email, + displayName: null, + avatarUrl: null, + status: 'connected', + grantedAt: '2026-09-05T12:00:00Z', + }, + ] + await render({ returnTo: 'search', optionId: 'site-two' }) + expect(document.body.textContent).toContain(`${principal.email} · Connected`) + expect(document.querySelector('h1')?.textContent).toBe('Confluence connected') + expect(oauthLinks()).toHaveLength(0) + expect(document.querySelector('form')).toBeNull() + expect(document.body.textContent).toContain('Return to Search') + }) + + it('does not treat a success query marker as a connected account', async () => { + await render({ + returnTo: 'search', + optionId: 'site-two', + connected: 'site-two', + mcp: 'connected', + mcpServerId: 'mcp-one', + }) + expect(oauthLinks()[0]?.textContent).toBe('Connect') + expect(document.body.textContent).toContain('Not connected') + expect(document.querySelector('[role="status"]')).toBeNull() + }) + + it('keeps the same focused Reconnect action after canceled authorization', async () => { + enrollment.options[1]!.connections = [ + { + email: principal.email, + displayName: null, + avatarUrl: null, + status: 'needs_reauth', + grantedAt: '2026-09-05T12:00:00Z', + }, + ] + await render({ returnTo: 'search', optionId: 'site-two', oauth: 'denied' }) + expect(oauthLinks()).toHaveLength(1) + expect(oauthLinks()[0]?.textContent).toBe('Reconnect') + expect(oauthLinks()[0]?.getAttribute('href')).toContain('/site-two?returnTo=search') + }) + + it('does not resolve enrollment metadata or trust a return workspace after authentication fails', async () => { + mocks.authenticate.mockResolvedValue(null) + await render({ returnTo: 'search', optionId: 'site-two', workspaceId: 'other-workspace' }) + expect(document.body.textContent).toContain('Invitation unavailable') + expect(mocks.read).not.toHaveBeenCalled() + expect(document.querySelector('a')).toBeNull() + }) +}) diff --git a/apps/sim/app/credential-groups/enroll/[token]/page.tsx b/apps/sim/app/credential-groups/enroll/[token]/page.tsx index 25a2a5ba955..790319a44f2 100644 --- a/apps/sim/app/credential-groups/enroll/[token]/page.tsx +++ b/apps/sim/app/credential-groups/enroll/[token]/page.tsx @@ -1,14 +1,22 @@ import { type ReactNode, Suspense } from 'react' -import { Chip } from '@sim/emcn' +import { Chip, ChipLink } from '@sim/emcn' import type { Metadata } from 'next' import { headers } from 'next/headers' +import { redirect } from 'next/navigation' +import { getSession } from '@/lib/auth' import { asOrchestrationError } from '@/lib/core/orchestration/types' +import type { ResourceOwner } from '@/lib/core/resource-scope' +import { resourceScopeFromOwner } from '@/lib/core/resource-scope' import { authenticateCredentialGroupEnrollment } from '@/lib/credential-groups/application/enrollment-auth' import { readPublicCredentialGroupEnrollment } from '@/lib/credential-groups/application/public-enrollment' +import { CredentialGroupEnrollmentError } from '@/lib/credential-groups/enrollments' import { getManagedMcpConnectorIcon } from '@/lib/credential-groups/managed-mcp-connector-icons' +import { CredentialGroupProviderConfigurationError } from '@/lib/credential-groups/provider-adapter' import { getCredentialGroupProviderService } from '@/lib/credential-groups/providers' import { enforcePublicCredentialGroupIpRateLimit } from '@/lib/credential-groups/rate-limit' -import { SupportFooter } from '@/app/(auth)/components' +import { organizationRoutes } from '@/lib/navigation/paths' +import { SEARCH_CONNECTORS } from '@/lib/sim-search/connectors' +import { AuthHeader, SupportFooter } from '@/app/(auth)/components' import { LogoShell } from '@/app/(landing)/components' import { OAuthConnectLink } from '@/app/credential-groups/enroll/[token]/oauth-reconnect-link' import { CredentialGroupOAuthToast } from '@/app/credential-groups/enroll/[token]/oauth-toast' @@ -44,18 +52,42 @@ function PageShell({ children }: PageShellProps) { ) } -function UnavailableInvitation({ rateLimited = false }: { rateLimited?: boolean }) { +interface UnavailableInvitationProps { + rateLimited?: boolean + message?: string +} + +function UnavailableInvitation({ rateLimited = false, message }: UnavailableInvitationProps) { return (
-

- {rateLimited ? 'Too many requests' : 'Invitation unavailable'} -

-

- {rateLimited - ? 'This link has been opened too many times. Wait a few minutes and try again.' - : 'This private link is invalid, expired, or has been revoked. Ask the workspace admin to send a new invitation.'} -

+ +
+
+ ) +} + +interface UnavailableSearchConnectionProps { + owner: ResourceOwner +} + +function UnavailableSearchConnection({ owner }: UnavailableSearchConnectionProps) { + return ( + + +
+ Return to Search
) @@ -92,19 +124,45 @@ export default async function CredentialGroupEnrollmentPage({ const { token } = await params if (!token || token.length > 128) return - + const resolvedSearchParams = await searchParams + const session = await getSession() + if (!session?.user) { + const callback = new URLSearchParams() + for (const key of ['returnTo', 'optionId']) { + const value = getSearchParam(resolvedSearchParams, key) + if (value) callback.set(key, value) + } + const callbackUrl = `/credential-groups/enroll/${encodeURIComponent(token)}${callback.size ? `?${callback}` : ''}` + redirect(`/login?callbackUrl=${encodeURIComponent(callbackUrl)}`) + } + if (!session.user.emailVerified) + return ( + + ) const principal = await authenticateCredentialGroupEnrollment(token) if (!principal) return + const returnToSearch = resolvedSearchParams.returnTo === 'search' + const requestedOptionId = resolvedSearchParams.optionId + const focusedOptionId = + typeof requestedOptionId === 'string' && requestedOptionId.length <= 128 + ? requestedOptionId + : '' const enrollmentResult = await readPublicCredentialGroupEnrollment - .execute({ principal, input: {} }) + .execute({ principal, input: returnToSearch ? { optionId: focusedOptionId } : {} }) .catch((error: unknown) => { + if (error instanceof CredentialGroupEnrollmentError) + return { enrollment: null, enrollmentError: error.message } if (asOrchestrationError(error)?.code === 'not_found') return null + if (returnToSearch && error instanceof CredentialGroupProviderConfigurationError) + return { enrollment: null } throw error }) if (!enrollmentResult) return + if ('enrollmentError' in enrollmentResult) + return const { enrollment } = enrollmentResult + if (!enrollment) return - const resolvedSearchParams = await searchParams const oauthStatus = getSearchParam(resolvedSearchParams, 'oauth') const connectedOptionId = getSearchParam(resolvedSearchParams, 'connected') const connectedMcpServerId = @@ -112,29 +170,44 @@ export default async function CredentialGroupEnrollmentPage({ ? getSearchParam(resolvedSearchParams, 'mcpServerId') : undefined const oauthMessage = - oauthStatus && oauthStatus in OAUTH_MESSAGES + oauthStatus && Object.hasOwn(OAUTH_MESSAGES, oauthStatus) ? OAUTH_MESSAGES[oauthStatus as keyof typeof OAUTH_MESSAGES] : null const activeOptions = enrollment.options.filter((option) => option.status === 'active') + const focusedOption = returnToSearch + ? activeOptions.find((option) => option.id === focusedOptionId) + : undefined + if (returnToSearch && !focusedOption) return + const visibleOptions = focusedOption ? [focusedOption] : activeOptions + const focusedConnected = focusedOption?.connections[0]?.status === 'connected' + const focusedProviderId = focusedOption + ? getCredentialGroupProviderService(focusedOption.provider).providerId + : undefined + const docsUrl = focusedProviderId + ? SEARCH_CONNECTORS.find((connector) => connector.providerIds.includes(focusedProviderId))?.meta + .searchDocsUrl + : undefined const connectedOption = connectedOptionId ? activeOptions.find((option) => option.id === connectedOptionId) : undefined const connectedMcpServer = connectedMcpServerId ? enrollment.mcpServers.find((server) => server.id === connectedMcpServerId) : undefined - const notification = connectedMcpServerId - ? { - message: `${connectedMcpServer?.name ?? 'MCP server'} connected successfully.`, - variant: 'success' as const, - } - : connectedOptionId + const notification = + !returnToSearch && connectedMcpServerId ? { - message: `${connectedOption ? getCredentialGroupProviderService(connectedOption.provider).name : 'Account'} connected successfully.`, + message: `${connectedMcpServer?.name ?? 'MCP server'} connected successfully.`, variant: 'success' as const, } - : oauthMessage - ? { message: oauthMessage, variant: 'error' as const } - : null + : connectedOptionId && + (!returnToSearch || (connectedOptionId === focusedOption?.id && focusedConnected)) + ? { + message: `${connectedOption ? getCredentialGroupProviderService(connectedOption.provider).name : 'Account'} connected successfully.`, + variant: 'success' as const, + } + : oauthMessage + ? { message: oauthMessage, variant: 'error' as const } + : null return ( {notification && ( @@ -142,28 +215,25 @@ export default async function CredentialGroupEnrollmentPage({ )} -
-

- Connect your accounts -

-

- {enrollment.inviterName ? ( - <> - {enrollment.inviterName}{' '} - invited you - - ) : ( - 'You have been invited' - )}{' '} - to connect accounts for{' '} - {enrollment.workspaceName}. -

-
+
- {activeOptions.map((option) => { + {visibleOptions.map((option) => { const ProviderIcon = getCredentialGroupProviderService(option.provider).icon const connection = option.connections[0] return ( @@ -171,51 +241,82 @@ export default async function CredentialGroupEnrollmentPage({ key={option.id} icon={} title={option.label} - description={connection?.email ?? 'Not connected'} - trailing={ - - } - /> - ) - })} - {enrollment.mcpServers.map((server) => { - const ConnectorIcon = getManagedMcpConnectorIcon(server.managedConnectorId) - return ( - } - title={server.name} description={ - server.connection?.status === 'connected' - ? 'Connected' - : server.connection - ? 'Reconnect required' - : server.description || 'Not connected' + returnToSearch && connection?.status === 'connected' + ? `${connection.email} · Connected` + : (connection?.email ?? 'Not connected') } trailing={ - + returnToSearch && connection?.status === 'connected' ? undefined : ( + + ) } /> ) })} + {!returnToSearch && + enrollment.mcpServers.map((server) => { + const ConnectorIcon = getManagedMcpConnectorIcon(server.managedConnectorId) + return ( + } + title={server.name} + description={ + server.connection?.status === 'connected' + ? 'Connected' + : server.connection + ? 'Reconnect required' + : server.description || 'Not connected' + } + trailing={ + + } + /> + ) + })}
- - - {enrollment.status === 'completed' ? 'Submitted' : 'Submit'} - - + {returnToSearch ? ( +
+ {docsUrl && ( + + Setup guide + + )} + + Return to Search + +
+ ) : ( +
+ + {enrollment.status === 'completed' ? 'Submitted' : 'Submit'} + +
+ )}
) } + +function searchReturnPath(owner: ResourceOwner): string { + const scope = resourceScopeFromOwner(owner) + return scope.kind === 'workspace' + ? `/workspace/${encodeURIComponent(scope.workspaceId)}/search` + : organizationRoutes(scope.organizationId).integrations +} diff --git a/apps/sim/app/desktop/connect/switch-account.tsx b/apps/sim/app/desktop/connect/switch-account.tsx index 4f132144855..d2be918b9e9 100644 --- a/apps/sim/app/desktop/connect/switch-account.tsx +++ b/apps/sim/app/desktop/connect/switch-account.tsx @@ -14,7 +14,7 @@ interface SwitchAccountProps { * callback. * * A plain link to `/login` would not work: the middleware bounces `/login` back - * to `/workspace` while any session cookie is set, so the wrong account has to + * to the app entry while any session cookie is set, so the wrong account has to * be cleared before the login page is reachable at all. For the same reason a * failed sign-out must not navigate — it would land the user right back where * they started with no explanation. diff --git a/apps/sim/app/enterprise/claim/[id]/enterprise-owner-claim.tsx b/apps/sim/app/enterprise/claim/[id]/enterprise-owner-claim.tsx index c5a0d690700..371c0b5420b 100644 --- a/apps/sim/app/enterprise/claim/[id]/enterprise-owner-claim.tsx +++ b/apps/sim/app/enterprise/claim/[id]/enterprise-owner-claim.tsx @@ -9,6 +9,7 @@ import { type EnterpriseOwnerClaimDetails, } from '@/lib/api/contracts/enterprise-owner-claims' import { client, useSession } from '@/lib/auth/auth-client' +import { APP_ENTRY_PATH } from '@/lib/navigation/paths' import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect' import { InviteLayout, InviteStatusCard } from '@/app/invite/components' import { useEnterpriseOwnerClaimDetails } from '@/hooks/queries/enterprise-owner-claims' @@ -265,7 +266,7 @@ export default function EnterpriseOwnerClaim({ registrationDisabled }: Enterpris label: 'Sign in to Enterprise', onClick: async () => { await client.signOut() - router.push(authLink('/login', '/workspace')) + router.push(authLink('/login', APP_ENTRY_PATH)) }, }, ] diff --git a/apps/sim/app/home/page.test.tsx b/apps/sim/app/home/page.test.tsx new file mode 100644 index 00000000000..90760f97c44 --- /dev/null +++ b/apps/sim/app/home/page.test.tsx @@ -0,0 +1,46 @@ +/** + * @vitest-environment node + */ +import { authMockFns } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockRedirect, mockResolveAppEntryPath } = vi.hoisted(() => ({ + mockRedirect: vi.fn((path: string) => { + throw new Error(`NEXT_REDIRECT:${path}`) + }), + mockResolveAppEntryPath: vi.fn(), +})) + +vi.mock('next/navigation', () => ({ + redirect: mockRedirect, +})) + +vi.mock('@/lib/navigation/resolve-app-entry', () => ({ + resolveAppEntryPath: mockResolveAppEntryPath, +})) + +import AppEntryPage from '@/app/home/page' + +const mockGetSession = authMockFns.mockGetSession + +describe('AppEntryPage', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('sends a signed-out visitor to login without resolving an entry', async () => { + mockGetSession.mockResolvedValue(null) + + await expect(AppEntryPage()).rejects.toThrow('NEXT_REDIRECT:/login') + expect(mockResolveAppEntryPath).not.toHaveBeenCalled() + }) + + it('forwards a signed-in viewer to their resolved entry', async () => { + const session = { user: { id: 'viewer' } } + mockGetSession.mockResolvedValue(session) + mockResolveAppEntryPath.mockResolvedValue('/o/org-1/home') + + await expect(AppEntryPage()).rejects.toThrow('NEXT_REDIRECT:/o/org-1/home') + expect(mockResolveAppEntryPath).toHaveBeenCalledWith(session) + }) +}) diff --git a/apps/sim/app/home/page.tsx b/apps/sim/app/home/page.tsx new file mode 100644 index 00000000000..1965f6894c2 --- /dev/null +++ b/apps/sim/app/home/page.tsx @@ -0,0 +1,18 @@ +import { redirect } from 'next/navigation' +import { getSession } from '@/lib/auth' +import { resolveAppEntryPath } from '@/lib/navigation/resolve-app-entry' + +/** + * The signed-in app's front door. Nothing renders here: the viewer is forwarded to + * their organization's home, or to their workspaces when they belong to none. Every + * default post-auth destination points at this route, so where a viewer lands is + * decided once, on the server, with their membership in hand. + */ +export default async function AppEntryPage() { + const session = await getSession() + if (!session?.user) { + redirect('/login') + } + + redirect(await resolveAppEntryPath(session)) +} diff --git a/apps/sim/app/invite/[id]/invite.tsx b/apps/sim/app/invite/[id]/invite.tsx index ffc75e40866..f762c4ae795 100644 --- a/apps/sim/app/invite/[id]/invite.tsx +++ b/apps/sim/app/invite/[id]/invite.tsx @@ -10,6 +10,7 @@ import { ApiClientError } from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' import { acceptInvitationContract } from '@/lib/api/contracts/invitations' import { client, useSession } from '@/lib/auth/auth-client' +import { APP_ENTRY_PATH } from '@/lib/navigation/paths' import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect' import { InviteLayout, InviteStatusCard } from '@/app/invite/components' import { useInvitationDetails } from '@/hooks/queries/invitations' @@ -459,7 +460,7 @@ export default function Invite({ registrationDisabled }: InviteProps) { description={error.message} icon='users' actions={[ - { label: 'Manage Team Settings', onClick: () => router.push('/workspace') }, + { label: 'Manage Team Settings', onClick: () => router.push(APP_ENTRY_PATH) }, { label: 'Return to Home', onClick: () => router.push('/') }, ]} /> diff --git a/apps/sim/app/layout.tsx b/apps/sim/app/layout.tsx index e3671e8792e..ab09b550b13 100644 --- a/apps/sim/app/layout.tsx +++ b/apps/sim/app/layout.tsx @@ -4,9 +4,11 @@ import Script from 'next/script' import { NuqsAdapter } from 'nuqs/adapters/next/app' import { BrandedLayout } from '@/components/branded-layout' import { PasteAdmissionGuard } from '@/app/_shell/paste-admission-guard' +import { BrowserTelemetry } from '@/app/_shell/providers/browser-telemetry' import { PostHogProvider } from '@/app/_shell/providers/posthog-provider' import { generateBrandedMetadata, generateThemeCSS } from '@/ee/whitelabeling' import '@/app/_styles/globals.css' +import { env } from '@/lib/core/config/env' import { isChatEnabled, isHosted, @@ -47,6 +49,10 @@ export default function RootLayout({ children }: { children: React.ReactNode }) + {children} @@ -102,19 +108,22 @@ export default function RootLayout({ children }: { children: React.ReactNode }) } } catch (e) {} + // The organization surface (/o/...) shares the workspace chrome and + // needs the same variables set before first paint. try { var path = window.location.pathname; - if (path.indexOf('/workspace/') === -1) { + if (path.indexOf('/workspace/') === -1 && path.indexOf('/o/') !== 0) { return; } } catch (e) { return; } - // Sidebar width. Mirror clampSidebarWidth() in stores/sidebar/store.ts: - // the upper bound can never fall below the 238px minimum, so a narrow - // window yields a width >= MIN instead of a sub-minimum sliver. - var defaultSidebarWidth = 238; + // Sidebar width. Mirror getMaxSidebarWidth() in stores/sidebar/store.ts: + // 30% of the viewport capped at 400px, and never below the 256px + // minimum, so a narrow window yields a width >= MIN instead of a + // sub-minimum sliver. + var defaultSidebarWidth = 256; try { // Collapse comes from the cookie (independent of localStorage // parsing); the persisted width is read defensively below. Match the @@ -140,10 +149,10 @@ export default function RootLayout({ children }: { children: React.ReactNode }) // collapsed, because the desktop hover-peek renders the sidebar at // its restore width while --sidebar-width still reads collapsed. var width = state && state.sidebarWidth; - var maxSidebarWidth = Math.max(238, window.innerWidth * 0.3); + var maxSidebarWidth = Math.max(256, Math.min(400, window.innerWidth * 0.3)); var expandedWidth = typeof width === 'number' && isFinite(width) - ? Math.min(Math.max(width, 238), maxSidebarWidth) + ? Math.min(Math.max(width, 256), maxSidebarWidth) : defaultSidebarWidth; document.documentElement.style.setProperty( '--sidebar-expanded-width', diff --git a/apps/sim/app/manifest.ts b/apps/sim/app/manifest.ts index 23e600614a0..a0e5f077e0c 100644 --- a/apps/sim/app/manifest.ts +++ b/apps/sim/app/manifest.ts @@ -1,4 +1,5 @@ import type { MetadataRoute } from 'next' +import { WORKSPACES_PATH } from '@/lib/navigation/paths' import { getBrandConfig } from '@/ee/whitelabeling' export const dynamic = 'force-dynamic' @@ -43,7 +44,7 @@ export default function manifest(): MetadataRoute.Manifest { name: 'Create Workflow', short_name: 'New', description: 'Create a new AI workflow', - url: '/workspace', + url: WORKSPACES_PATH, }, ], lang: 'en-US', diff --git a/apps/sim/app/o/[organizationId]/chat/[chatId]/page.tsx b/apps/sim/app/o/[organizationId]/chat/[chatId]/page.tsx new file mode 100644 index 00000000000..d05f2568244 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/chat/[chatId]/page.tsx @@ -0,0 +1,27 @@ +import type { Metadata } from 'next' +import { notFound, redirect } from 'next/navigation' +import { getSession } from '@/lib/auth' +import { getAccessibleCopilotChatAuth } from '@/lib/copilot/chat/lifecycle' +import { WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths' +import { getOrganizationSurfaceContext } from '@/lib/organizations/surface' +import { OrganizationHome } from '@/app/o/[organizationId]/home/organization-home' + +export const metadata: Metadata = { title: 'Chat' } + +export default async function OrganizationChatPage({ + params, +}: { + params: Promise<{ organizationId: string; chatId: string }> +}) { + const { organizationId, chatId } = await params + const session = await getSession() + if (!session?.user?.id) notFound() + const context = await getOrganizationSurfaceContext(organizationId, session.user.id) + if (!context) notFound() + if (!context.searchAccess.memberScoped) redirect(WORKSPACE_SETTINGS_PATH) + const chat = await getAccessibleCopilotChatAuth(chatId, session.user.id, { + principal: { kind: 'session', userId: session.user.id, sessionId: session.session.id }, + }) + if (!chat || chat.type !== 'mothership' || chat.organizationId !== organizationId) notFound() + return +} diff --git a/apps/sim/app/o/[organizationId]/components/organization-access-denied.tsx b/apps/sim/app/o/[organizationId]/components/organization-access-denied.tsx new file mode 100644 index 00000000000..6a4bb7647f1 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-access-denied.tsx @@ -0,0 +1,27 @@ +import { ChipLink } from '@sim/emcn' +import { CircleAlert } from '@sim/emcn/icons' +import { WORKSPACES_PATH } from '@/lib/navigation/paths' +import { DesktopTitleBarLane } from '@/app/_shell/desktop-title-bar' + +export function OrganizationAccessDenied() { + return ( +
+ +
+
+ +
+
+

Organization access denied

+

+ You are not a member of this organization. Ask an organization admin to add you, or head + back to your workspaces. +

+
+ + View your workspaces + +
+
+ ) +} diff --git a/apps/sim/app/o/[organizationId]/components/organization-page/index.ts b/apps/sim/app/o/[organizationId]/components/organization-page/index.ts new file mode 100644 index 00000000000..ffdb2e714bd --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-page/index.ts @@ -0,0 +1,6 @@ +export { + OrganizationPage, + type OrganizationPageTab, + PAGE_COLUMN_CLASS, +} from '@/app/o/[organizationId]/components/organization-page/organization-page' +export { useOrganizationPageFilters } from '@/app/o/[organizationId]/components/organization-page/use-organization-page-filters' diff --git a/apps/sim/app/o/[organizationId]/components/organization-page/organization-page.tsx b/apps/sim/app/o/[organizationId]/components/organization-page/organization-page.tsx new file mode 100644 index 00000000000..f3f43caf421 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-page/organization-page.tsx @@ -0,0 +1,174 @@ +'use client' + +import { type ReactNode, useRef, useState } from 'react' +import { + Button, + Chip, + ChipInput, + cn, + scrollFadeAttributes, + scrollFadeClass, + scrollFadeXClass, + useScrollEdges, +} from '@sim/emcn' +import { Search, X } from '@sim/emcn/icons' +import { HEADER_ACTION_CLUSTER, PAGE_HEADER_BAR } from '@/components/page-header-bar' +import { useOrganizationPageFilters } from '@/app/o/[organizationId]/components/organization-page/use-organization-page-filters' +import { + SIDEBAR_DIVIDER_PAD_ABOVE_CLASS, + SIDEBAR_DIVIDER_PAD_BELOW_CLASS, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/constants' + +/** The home surface's reading column, so every organization page shares its width. */ +export const PAGE_COLUMN_CLASS = 'mx-auto w-full max-w-chat px-6' + +export interface OrganizationPageTab { + id: string + label: string +} + +interface OrganizationPageProps { + title: string + description?: string + /** Header tabs; the first is the default. Omit for a page with one view. */ + tabs?: readonly OrganizationPageTab[] + /** The page's primary action, a chip. Omit for a page without one. */ + action?: ReactNode + children?: ReactNode +} + +/** + * The shell every organization page renders into: the top bar the workspace pages + * wear, then a fixed page header — title, description, tabs, search, and the + * optional action — over a scroll region that fades at both edges the way the + * sidebar does. Pages supply their content and logic; nothing else. + * + * The shell paints at once and never waits on data: a page renders each piece — + * a tab, a list, a count — the moment it is known and nothing before, with no + * skeleton standing in for it. Pass `tabs` only once they are known; the row + * simply gains them. + */ +export function OrganizationPage({ + title, + description, + tabs, + action, + children, +}: OrganizationPageProps) { + const scrollContainerRef = useRef(null) + const scrollContentRef = useRef(null) + const scrollEdges = useScrollEdges(scrollContainerRef, { contentRef: scrollContentRef }) + const tabsRef = useRef(null) + const tabEdges = useScrollEdges(tabsRef, { axis: 'x' }) + + const { tab, search, setTab, setSearch } = useOrganizationPageFilters() + const defaultTab = tabs?.[0]?.id + const activeTab = tab ?? defaultTab + + /** + * The field stays open while it holds text, across tab switches and reloads, + * since the text lives in the URL; this only remembers an empty field the + * viewer opened and has not dismissed. + */ + const [searchOpened, setSearchOpened] = useState(false) + const searchOpen = searchOpened || search.length > 0 + + const closeSearch = () => { + setSearch('') + setSearchOpened(false) + } + + return ( +
+ {/* Reserved even while empty so the page header sits where the workspace's does. */} +
+
+
+ +
+
+

{title}

+ {description &&

{description}

} +
+
+ {/* The row yields to the controls beside it and scrolls sideways under a fade + once it can no longer fit; the scrollbar itself never shows. */} +
+ {tabs?.map((item) => { + const active = item.id === activeTab + return ( + setTab(item.id === defaultTab ? null : item.id)} + className='min-w-[44px] shrink-0 text-center' + > + {item.label} + + ) + })} +
+
+ {searchOpen ? ( + setSearch(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Escape') closeSearch() + }} + endAdornment={ + + } + /> + ) : ( + setSearchOpened(true)} /> + )} + {action} +
+
+
+ +
+
+ {children} +
+
+
+ ) +} diff --git a/apps/sim/app/o/[organizationId]/components/organization-page/search-params.ts b/apps/sim/app/o/[organizationId]/components/organization-page/search-params.ts new file mode 100644 index 00000000000..2be9ae79290 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-page/search-params.ts @@ -0,0 +1,22 @@ +import { parseAsString } from 'nuqs/server' + +/** + * Co-located, typed URL query-param definitions for an organization page's + * header. Both are view state the page's content filters by, so a link carries + * them and a tab switch keeps them. + * + * - `tab` is the active header tab. Absent, the page shows its first tab, so the + * key only appears once the viewer leaves it. + * - `q` is the search field's text, written raw (consumers trim on read) and + * debounced on the way to the URL by `useDebouncedSearchSetter`. + */ +export const organizationPageParsers = { + tab: parseAsString, + q: parseAsString.withDefault(''), +} as const + +/** Tabs and search are filter-like view changes, not navigation: replace, and clear at the default. */ +export const organizationPageUrlKeys = { + history: 'replace', + clearOnDefault: true, +} as const diff --git a/apps/sim/app/o/[organizationId]/components/organization-page/use-organization-page-filters.ts b/apps/sim/app/o/[organizationId]/components/organization-page/use-organization-page-filters.ts new file mode 100644 index 00000000000..371ae5527af --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-page/use-organization-page-filters.ts @@ -0,0 +1,21 @@ +import { useCallback } from 'react' +import { useQueryStates } from 'nuqs' +import { + organizationPageParsers, + organizationPageUrlKeys, +} from '@/app/o/[organizationId]/components/organization-page/search-params' +import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter' + +/** + * The header filters of the organization page the caller sits on. The shell + * drives them; a page's content reads `tab` and `search` to filter its list, so + * the same criteria apply whichever tab is showing. + */ +export function useOrganizationPageFilters() { + const [{ tab, q }, setFilters] = useQueryStates(organizationPageParsers, organizationPageUrlKeys) + + const setTab = useCallback((next: string | null) => setFilters({ tab: next }), [setFilters]) + const setSearch = useDebouncedSearchSetter((value, options) => setFilters({ q: value }, options)) + + return { tab, search: q, setTab, setSearch } +} diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.test.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.test.tsx new file mode 100644 index 00000000000..c95da914201 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.test.tsx @@ -0,0 +1,121 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { OrganizationChat } from '@/app/o/[organizationId]/components/organization-sidebar/hooks' + +const hoverState = vi.hoisted(() => ({ isOpen: false })) + +vi.mock('next/link', () => ({ + default: ({ href, children, ...props }: { href: string; children: React.ReactNode }) => ( + + {children} + + ), +})) +vi.mock('@/app/workspace/[workspaceId]/w/components/sidebar/hooks', () => ({ + useHoverMenu: () => ({ + isOpen: hoverState.isOpen, + open: vi.fn(), + close: vi.fn(), + setLocked: vi.fn(), + triggerProps: { onMouseEnter: vi.fn(), onMouseLeave: vi.fn() }, + contentProps: { onMouseEnter: vi.fn(), onMouseLeave: vi.fn(), onCloseAutoFocus: vi.fn() }, + }), +})) + +import { ChatsSection } from '@/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section' + +const CHATS: OrganizationChat[] = Array.from({ length: 8 }, (_, index) => ({ + id: `chat-${index + 1}`, + name: `Chat ${index + 1}`, + href: `/o/org-1/chat/chat-${index + 1}`, +})) + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + } + ) + hoverState.isOpen = false + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(async () => { + await act(async () => root.unmount()) + container.remove() + vi.unstubAllGlobals() +}) + +async function render(props: Partial[0]> = {}) { + await act(async () => { + root.render( + {}} + onMoreClick={() => {}} + {...props} + /> + ) + }) +} + +describe('ChatsSection', () => { + it('lists every chat with no paging control', async () => { + await render() + + expect(container.querySelectorAll('a[href^="/o/org-1/chat/"]')).toHaveLength(8) + expect(container.textContent).not.toContain('See more') + }) + + it('marks the chat on the current route active', async () => { + await render({ pathname: '/o/org-1/chat/chat-3' }) + + const current = container.querySelector('a[href="/o/org-1/chat/chat-3"]') + const other = container.querySelector('a[href="/o/org-1/chat/chat-4"]') + expect(current?.className).toContain('surface-active') + expect(other?.className).not.toContain('surface-active') + }) + + it('reports the row href when its options button is pressed', async () => { + const onMoreClick = vi.fn() + await render({ onMoreClick }) + + const button = container.querySelector( + 'a[href="/o/org-1/chat/chat-2"] button[aria-label="Chat options"]' + ) + await act(async () => button?.click()) + + expect(onMoreClick).toHaveBeenCalledWith(expect.anything(), '/o/org-1/chat/chat-2') + }) + + it('shows the empty state when there are no chats', async () => { + await render({ chats: [] }) + expect(container.textContent).toContain('No chats yet') + }) + + it('renders the flyout rows while collapsed', async () => { + hoverState.isOpen = true + await render({ isCollapsed: true }) + + expect(container.querySelector('[aria-label="Chats"]')).not.toBeNull() + /* Radix portals the flyout to the body. */ + expect(document.body.querySelectorAll('a[href^="/o/org-1/chat/"]')).toHaveLength(8) + }) +}) diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx new file mode 100644 index 00000000000..042bca94ad8 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx @@ -0,0 +1,183 @@ +'use client' + +import { chipVariants, cn, DropdownMenuItem, Loader, OverflowText, Skeleton } from '@sim/emcn' +import { MoreHorizontal, Pin, Task } from '@sim/emcn/icons' +import Link from 'next/link' +import type { OrganizationChat } from '@/app/o/[organizationId]/components/organization-sidebar/hooks' +import { ConversationListItem } from '@/app/workspace/[workspaceId]/components' +import { + CollapsedSidebarMenu, + SidebarSection, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/components' +import { + SIDEBAR_ITEM_GAP_CLASS, + SIDEBAR_SECTION_GAP_CLASS, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/constants' +import { useHoverMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' + +/** Stands in for a chip row while the list loads, so it carries no margin either. */ +function ChatRowSkeleton() { + return ( +
+ +
+ ) +} + +interface ChatRowProps { + chat: OrganizationChat + isCurrentRoute: boolean + isMenuOpen: boolean + onContextMenu: (e: React.MouseEvent, href: string) => void + onMoreClick: (e: React.MouseEvent, href: string) => void +} + +function ChatRow({ chat, isCurrentRoute, isMenuOpen, onContextMenu, onMoreClick }: ChatRowProps) { + /** + * The trailing slot fits one glyph, and the dot wins over the pin: it reports + * transient state (a run in progress, or an unread reply elsewhere), while pinning + * is persistent and already conveyed by the row sorting to the top of the list. + */ + const showStatusDot = Boolean(chat.isActive) || (!isCurrentRoute && Boolean(chat.isUnread)) + + return ( + onContextMenu(e, chat.href)} + > + +
+ {showStatusDot && ( +
+ + ) +} + +interface ChatsSectionProps { + chats: OrganizationChat[] + isLoading: boolean + isCollapsed: boolean + pathname: string | null + /** Href of the row whose options menu is open, so it stays highlighted meanwhile. */ + menuOpenHref: string | null + onContextMenu: (e: React.MouseEvent, href: string) => void + onMoreClick: (e: React.MouseEvent, href: string) => void +} + +/** + * The organization's chats, the section beneath Workspaces, spaced from it by the + * section gap exactly as the workspace sidebar spaces its own sections. Expanded, a + * collapsible list of every chat — no paging, the scroll region carries the length; + * collapsed, a hover flyout off the rail glyph. + */ +export function ChatsSection({ + chats, + isLoading, + isCollapsed, + pathname, + menuOpenHref, + onContextMenu, + onMoreClick, +}: ChatsSectionProps) { + const hover = useHoverMenu() + + return ( + + {isCollapsed ? ( +
+ } + hover={hover} + ariaLabel='Chats' + > + {isLoading ? ( + + + Loading... + + ) : chats.length === 0 ? ( + No chats yet + ) : ( + chats.map((chat) => { + const isCurrentRoute = pathname === chat.href + return ( + + onContextMenu(e, chat.href)}> + + + + ) + }) + )} + +
+ ) : ( +
+ {isLoading ? ( + + ) : ( + <> + {chats.length === 0 && ( +
+ No chats yet +
+ )} + {chats.map((chat) => ( + + ))} + + )} +
+ )} +
+ ) +} diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/index.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/index.ts new file mode 100644 index 00000000000..a2ee28fffc9 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/index.ts @@ -0,0 +1 @@ +export { ChatsSection } from './chats-section' diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/index.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/index.ts new file mode 100644 index 00000000000..525cf120d1d --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/index.ts @@ -0,0 +1,5 @@ +export { ChatsSection } from './chats-section' +export { OrganizationFooter } from './organization-footer' +export { OrganizationHeader } from './organization-header' +export { WorkspacesRailFlyout } from './workspaces-rail-flyout' +export { WorkspacesSection } from './workspaces-section' diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/index.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/index.ts new file mode 100644 index 00000000000..95078897371 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/index.ts @@ -0,0 +1 @@ +export { OrganizationFooter } from './organization-footer' diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/organization-footer.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/organization-footer.tsx new file mode 100644 index 00000000000..3abcc00a6f2 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-footer/organization-footer.tsx @@ -0,0 +1,240 @@ +'use client' + +import type { DesktopUpdateState } from '@sim/desktop-bridge' +import { + Chip, + chipContentLabelClass, + chipPrimaryFillTokens, + chipVariants, + cn, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuItemLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, + OverflowText, + Skeleton, +} from '@sim/emcn' +import { BookOpen, Download, HelpCircle, Settings } from '@sim/emcn/icons' +import Link from 'next/link' +import { SlackIcon } from '@/components/icons' +import { getDesktopUpdates } from '@/lib/desktop' +import { organizationRoutes } from '@/lib/navigation/paths' +import { getUserColor } from '@/lib/workspaces/colors' +import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' +import { SidebarTooltip } from '@/app/workspace/[workspaceId]/w/components/sidebar/components' +import { + SIDEBAR_ITEM_GAP_CLASS, + SIDEBAR_RAIL_CHIP_CLASS, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/constants' +import { useUserProfile } from '@/hooks/queries/user-profile' +import { useDesktopUpdateState } from '@/hooks/use-desktop-update-state' + +function hasAvailableDesktopUpdate(state: DesktopUpdateState): boolean { + return state.status === 'available' || state.status === 'downloading' || state.status === 'ready' +} + +function desktopUpdateActionLabel(state: DesktopUpdateState): string { + if (state.status === 'downloading') { + return state.percent === undefined + ? 'Downloading update…' + : `Downloading update ${state.percent}%` + } + return state.status === 'ready' ? 'Restart to update' : 'Update' +} + +/** Compact primary update circle using the same footprint as the surrounding sidebar icons. */ +function DesktopUpdateIcon({ className }: { className?: string }) { + return ( +
+ {/* Download's default viewBox is asymmetric around its paths. Center the + artwork itself, not merely its SVG box, inside the avatar-sized circle. */} + +
+ ) +} + +interface OrganizationFooterProps { + /** + * True while the scroll region above still hides rows beyond its bottom edge — + * the same test the divider under the pinned nav applies at the top. The bar's + * top rule is drawn only then, so a list that fits meets the footer with no line. + */ + showDivider: boolean + isCollapsed: boolean + showCollapsedTooltips: boolean + onOpenDocs: () => void + onJoinSlack: () => void +} + +/** + * Pinned bottom bar of the organization sidebar: the viewer's avatar and name, + * which open their account settings, plus a help menu. Same two elements and the + * same layout as the workspace footer — expanded they share one row with help hard + * right, collapsed they stack as icon chips with help on top. + * + * Collapsed reverses the flex direction instead of reordering the DOM, which keeps + * both elements (and the help menu's trigger) alive across a toggle. + */ +export function OrganizationFooter({ + showDivider, + isCollapsed, + showCollapsedTooltips, + onOpenDocs, + onJoinSlack, +}: OrganizationFooterProps) { + const { organization } = useOrganizationContext() + const { data: profile } = useUserProfile() + const updateState = useDesktopUpdateState() + + const name = profile ? profile.name?.trim() || profile.email : '' + const updateAvailable = hasAvailableDesktopUpdate(updateState) + + const handleUpdateSelect = () => { + const updates = getDesktopUpdates() + if (updateState.status === 'ready') { + updates?.install() + } else if (updateState.status === 'available') { + updates?.check() + } + } + + /** + * Plain `img`/`div` rather than the emcn `Avatar`, whose Radix root renders a + * `` — and globals fade every `span` in the collapsed rail to `opacity: 0`, + * which would blank the avatar exactly where it is the only thing left to see. + */ + const avatar = !profile ? ( + + ) : profile.image ? ( + + ) : ( +
+ {name.charAt(0).toUpperCase()} +
+ ) + + /** + * Expanded, the chip hugs its content (`max-w-full` so a long name truncates + * rather than overflowing); collapsed, `fullWidth` fills the narrow rail and + * `min-w-0` lets the hidden label give up its box so the chip never overflows it. + * The name is the button's accessible name — no `aria-label`, which would + * override the visible text. + */ + const profileMenu = ( + + + + + + + + + + + + + + + + ) + + /** + * One node across both states; only `fullWidth` changes, so the same Radix menu + * survives the transition. `shrink-0` keeps the chip off the avatar while the rail + * is briefly narrower than the row — the aside's clip hides it until there is room. + */ + const helpMenu = ( + + + + + + + {/* Anchored to whichever edge the trigger sits on, so the menu never overhangs the rail. */} + + {updateAvailable && ( + <> + + + {desktopUpdateActionLabel(updateState)} + + + + )} + + + Docs + + + + Join Slack + + + + ) + + return ( +
+ {/* Expanded, claims the row's free width so the help button lands hard right. + `flex` makes the inline-flex chip a flex item, so the wrapper is exactly the + chip's 30px rather than a line box padded by the strut's half-leading. */} +
{profileMenu}
+ {helpMenu} +
+ ) +} diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/index.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/index.ts new file mode 100644 index 00000000000..7040e44e82d --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/index.ts @@ -0,0 +1 @@ +export { OrganizationHeader } from './organization-header' diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/organization-header.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/organization-header.tsx new file mode 100644 index 00000000000..c007e5d395d --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/organization-header/organization-header.tsx @@ -0,0 +1,117 @@ +'use client' + +import { + Chip, + ChipChevronDown, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@sim/emcn' +import { PanelLeft, Settings } from '@sim/emcn/icons' +import { IdentityTile } from '@/components/identity-tile/identity-tile' +import { getOrganizationSettingsHref } from '@/components/settings/navigation' +import { SettingsGuardedLink } from '@/components/settings/settings-guarded-link' +import type { OrganizationSurfaceOrganization } from '@/lib/organizations/surface' +import { SIDEBAR_RAIL_CHIP_CLASS } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants' + +function getOrganizationInitial(name: string): string { + return (name.trim()[0] || 'O').toUpperCase() +} + +interface OrganizationHeaderProps { + organization: OrganizationSurfaceOrganization + isCollapsed: boolean + /** Expands the rail; the collapsed header is itself the expand control. */ + onExpandSidebar: () => void +} + +/** + * The top-left organization chip. Expanded, it names the organization and opens + * its card — the mark at tile size, the name, how many people belong, and the way + * into its settings; collapsed, it becomes the rail's expand control, swapping + * the mark for a panel glyph on hover exactly as the workspace header does. The + * mark is the organization's uploaded logo or its initial on the neutral tile. + */ +export function OrganizationHeader({ + organization, + isCollapsed, + onExpandSidebar, +}: OrganizationHeaderProps) { + const initial = getOrganizationInitial(organization.name) + + if (isCollapsed) { + return ( +
+ + + +
+ } + /> +
+ ) + } + + const { memberCount } = organization + + return ( +
+ + + } + rightAdornment={} + > + {organization.name} + + + + {/* The item rows' `px-2` and the rail chips' icon-to-label gap, so the card sits on the menu's own grid. */} +
+ +
+ + {organization.name} + + + {memberCount} {memberCount === 1 ? 'member' : 'members'} + +
+
+ + + + Settings + + +
+
+
+ ) +} diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-rail-flyout/index.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-rail-flyout/index.ts new file mode 100644 index 00000000000..fe0024ab47c --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-rail-flyout/index.ts @@ -0,0 +1 @@ +export { WorkspacesRailFlyout } from './workspaces-rail-flyout' diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-rail-flyout/workspaces-rail-flyout.test.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-rail-flyout/workspaces-rail-flyout.test.tsx new file mode 100644 index 00000000000..b6729ecbf07 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-rail-flyout/workspaces-rail-flyout.test.tsx @@ -0,0 +1,97 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const workspacesState = vi.hoisted(() => ({ + workspaces: [] as { id: string; name: string }[], + isLoading: false, +})) + +vi.mock('next/link', () => ({ + default: ({ href, children, ...props }: { href: string; children: React.ReactNode }) => ( + + {children} + + ), +})) +vi.mock('@/app/o/[organizationId]/components/organization-sidebar/hooks', () => ({ + useOrganizationWorkspaces: () => workspacesState, +})) +vi.mock( + '@/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu', + () => ({ + CollapsedResourceFlyout: ({ + entries, + isLoading, + emptyLabel, + }: { + entries: { id: string; name: string; href: string }[] + isLoading: boolean + emptyLabel: string + }) => + isLoading ? ( + Loading... + ) : entries.length === 0 ? ( + {emptyLabel} + ) : ( + entries.map((entry) => ( + + {entry.name} + + )) + ), + }) +) + +import { WorkspacesRailFlyout } from '@/app/o/[organizationId]/components/organization-sidebar/components/workspaces-rail-flyout/workspaces-rail-flyout' + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + workspacesState.workspaces = [] + workspacesState.isLoading = false + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(async () => { + await act(async () => root.unmount()) + container.remove() +}) + +async function render() { + await act(async () => { + root.render() + }) +} + +describe('WorkspacesRailFlyout', () => { + it('lists every workspace as a link into it', async () => { + workspacesState.workspaces = [ + { id: 'ws-1', name: 'Design' }, + { id: 'ws-2', name: 'Ops' }, + ] + await render() + + const links = Array.from(container.querySelectorAll('a')).map((a) => a.getAttribute('href')) + expect(links).toEqual(['/workspace/ws-1', '/workspace/ws-2']) + expect(container.textContent).toContain('Design') + }) + + it('shows the empty label when the organization has no workspaces', async () => { + await render() + expect(container.textContent).toContain('No workspaces yet') + }) + + it('shows the loading row while the list resolves', async () => { + workspacesState.isLoading = true + await render() + expect(container.textContent).toContain('Loading...') + }) +}) diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-rail-flyout/workspaces-rail-flyout.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-rail-flyout/workspaces-rail-flyout.tsx new file mode 100644 index 00000000000..b36e478cf14 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-rail-flyout/workspaces-rail-flyout.tsx @@ -0,0 +1,35 @@ +'use client' + +import { useOrganizationWorkspaces } from '@/app/o/[organizationId]/components/organization-sidebar/hooks' +import type { FlyoutEntry } from '@/app/workspace/[workspaceId]/components/folders' +import { CollapsedResourceFlyout } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu' + +interface WorkspacesRailFlyoutProps { + organizationId: string +} + +/** + * Rail flyout body for the Workspaces tab: a jump list of the organization's + * workspaces, one row each, the way the workspace sidebar's Tables and Files tabs + * list theirs. Mounts only while the rail menu is open, so the workspace query + * runs only when someone hovers the chip. + */ +export function WorkspacesRailFlyout({ organizationId }: WorkspacesRailFlyoutProps) { + const { workspaces, isLoading } = useOrganizationWorkspaces(organizationId) + + const entries: FlyoutEntry[] = workspaces.map((workspace) => ({ + kind: 'item', + id: workspace.id, + name: workspace.name, + pinned: false, + href: `/workspace/${workspace.id}`, + })) + + return ( + + ) +} diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/index.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/index.ts new file mode 100644 index 00000000000..48d480cdd44 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/index.ts @@ -0,0 +1 @@ +export { WorkspacesSection } from './workspaces-section' diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspaces-section.test.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspaces-section.test.tsx new file mode 100644 index 00000000000..7505e56a100 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspaces-section.test.tsx @@ -0,0 +1,139 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const state = vi.hoisted(() => ({ + isOpen: false, + workspaces: [] as { id: string; name: string; logoUrl: null }[], + isLoading: false, +})) + +vi.mock('next/link', () => ({ + default: ({ href, children, ...props }: { href: string; children: React.ReactNode }) => ( + + {children} + + ), +})) +vi.mock('@/app/workspace/[workspaceId]/w/components/sidebar/hooks', () => ({ + useHoverMenu: () => ({ + isOpen: state.isOpen, + open: vi.fn(), + close: vi.fn(), + setLocked: vi.fn(), + triggerProps: { onMouseEnter: vi.fn(), onMouseLeave: vi.fn() }, + contentProps: { onMouseEnter: vi.fn(), onMouseLeave: vi.fn(), onCloseAutoFocus: vi.fn() }, + }), +})) +vi.mock('@/app/o/[organizationId]/components/organization-sidebar/hooks', () => ({ + useOrganizationWorkspaces: () => ({ workspaces: state.workspaces, isLoading: state.isLoading }), +})) + +import { WorkspacesSection } from '@/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspaces-section' + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + } + ) + state.isOpen = false + state.isLoading = false + state.workspaces = Array.from({ length: 8 }, (_, index) => ({ + id: `ws-${index + 1}`, + name: `Workspace ${index + 1}`, + logoUrl: null, + })) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(async () => { + await act(async () => root.unmount()) + container.remove() + vi.unstubAllGlobals() +}) + +async function render(props: Partial[0]> = {}) { + await act(async () => { + root.render( + {}} + {...props} + /> + ) + }) +} + +function rows() { + return container.querySelectorAll('a[href^="/workspace/"]') +} + +function pager() { + return Array.from(container.querySelectorAll('button')).find((button) => + /See (more|less)/.test(button.textContent ?? '') + ) +} + +describe('WorkspacesSection', () => { + it('shows the first page and pages the rest in like the sidebar chats', async () => { + await render() + expect(rows()).toHaveLength(5) + expect(pager()?.textContent).toBe('See more') + + await act(async () => pager()?.click()) + expect(rows()).toHaveLength(8) + expect(pager()?.textContent).toBe('See less') + + await act(async () => pager()?.click()) + expect(rows()).toHaveLength(5) + }) + + it('offers no pager when the list fits the first page', async () => { + state.workspaces = state.workspaces.slice(0, 3) + await render() + expect(rows()).toHaveLength(3) + expect(pager()).toBeUndefined() + }) + + it('marks the workspace on the current route active', async () => { + await render({ pathname: '/workspace/ws-2' }) + expect(container.querySelector('a[href="/workspace/ws-2"]')?.className).toContain( + 'surface-active' + ) + expect(container.querySelector('a[href="/workspace/ws-1"]')?.className).not.toContain( + 'surface-active' + ) + }) + + it('shows the empty state only once the list has resolved', async () => { + state.workspaces = [] + state.isLoading = true + await render() + expect(container.textContent).not.toContain('No workspaces yet') + + state.isLoading = false + await render() + expect(container.textContent).toContain('No workspaces yet') + }) + + it('renders the flyout while collapsed', async () => { + state.isOpen = true + await render({ isCollapsed: true }) + expect(container.querySelector('[aria-label="Workspaces"]')).not.toBeNull() + }) +}) diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspaces-section.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspaces-section.tsx new file mode 100644 index 00000000000..5e64b48fa75 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/workspaces-section/workspaces-section.tsx @@ -0,0 +1,98 @@ +'use client' + +import { useState } from 'react' +import { chipVariants, cn, OverflowText } from '@sim/emcn' +import { Workspaces } from '@sim/emcn/icons' +import Link from 'next/link' +import { IdentityTile } from '@/components/identity-tile/identity-tile' +import { getWorkspaceInitial } from '@/lib/workspaces/initials' +import { WorkspacesRailFlyout } from '@/app/o/[organizationId]/components/organization-sidebar/components/workspaces-rail-flyout' +import { useOrganizationWorkspaces } from '@/app/o/[organizationId]/components/organization-sidebar/hooks' +import { + CollapsedSidebarMenu, + SidebarSection, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/components' +import { SIDEBAR_ITEM_GAP_CLASS } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants' +import { useHoverMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' + +/** Rows shown at first, and added per "See more" — the workspace sidebar's Chats paging. */ +const PAGE_SIZE = 5 + +interface WorkspacesSectionProps { + organizationId: string + isCollapsed: boolean + pathname: string | null + onContextMenu: (e: React.MouseEvent, href: string) => void +} + +/** + * The organization's workspaces the viewer belongs to: the first section of the + * scroll region, so it carries no section gap — the divider padding above it is + * the whole distance, exactly as the workspace sidebar spaces its own Chats. + * Expanded, five rail chips and a muted "See more" that pages the rest in, the way + * the workspace sidebar pages its Chats; collapsed, a hover flyout off the rail glyph. + */ +export function WorkspacesSection({ + organizationId, + isCollapsed, + pathname, + onContextMenu, +}: WorkspacesSectionProps) { + const hover = useHoverMenu() + const { workspaces, isLoading } = useOrganizationWorkspaces(organizationId) + const [visibleCount, setVisibleCount] = useState(PAGE_SIZE) + const hasMore = workspaces.length > visibleCount + + return ( + + {isCollapsed ? ( +
+ } + hover={hover} + ariaLabel='Workspaces' + > + + +
+ ) : ( +
+ {!isLoading && workspaces.length === 0 && ( +
+ No workspaces yet +
+ )} + {workspaces.slice(0, visibleCount).map((workspace) => { + const href = `/workspace/${workspace.id}` + return ( + onContextMenu(e, href)} + > + + + + ) + })} + {workspaces.length > PAGE_SIZE && ( + + )} +
+ )} +
+ ) +} diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/index.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/index.ts new file mode 100644 index 00000000000..c96914ad41e --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/index.ts @@ -0,0 +1,4 @@ +export { useCollapsedTooltips } from './use-collapsed-tooltips' +export type { OrganizationChat } from './use-organization-chats' +export { useOrganizationChats } from './use-organization-chats' +export { useOrganizationWorkspaces } from './use-organization-workspaces' diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-collapsed-tooltips.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-collapsed-tooltips.ts new file mode 100644 index 00000000000..b63dc15c591 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-collapsed-tooltips.ts @@ -0,0 +1,23 @@ +import { useEffect, useState } from 'react' + +/** How long the rail takes to settle after collapsing before row tooltips arm. */ +const COLLAPSED_TOOLTIP_DELAY_MS = 200 + +/** + * Whether collapsed-rail tooltips should render. Arming is delayed past the rail's + * width animation so a tooltip never flashes beside a label that is still fading + * out; disarming is immediate so the expanded rail never shows one. + */ +export function useCollapsedTooltips(isCollapsed: boolean): boolean { + const [showCollapsedTooltips, setShowCollapsedTooltips] = useState(isCollapsed) + + useEffect(() => { + if (isCollapsed) { + const timer = setTimeout(() => setShowCollapsedTooltips(true), COLLAPSED_TOOLTIP_DELAY_MS) + return () => clearTimeout(timer) + } + setShowCollapsedTooltips(false) + }, [isCollapsed]) + + return isCollapsed && showCollapsedTooltips +} diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-chats.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-chats.ts new file mode 100644 index 00000000000..0bff17ebe9e --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-chats.ts @@ -0,0 +1,26 @@ +import { useOrganizationMothershipChats } from '@/hooks/queries/mothership-chats' + +export interface OrganizationChat { + id: string + name: string + href: string + /** A run is in progress. */ + isActive?: boolean + /** Has a reply the viewer has not opened. */ + isUnread?: boolean + isPinned?: boolean +} + +/** Lists only the current member's private organization conversations. */ +export function useOrganizationChats(organizationId: string) { + const query = useOrganizationMothershipChats(organizationId) + const chats: OrganizationChat[] = (query.data ?? []).map((chat) => ({ + id: chat.id, + name: chat.name, + href: `/o/${organizationId}/chat/${chat.id}`, + isActive: chat.isActive, + isUnread: chat.isUnread, + isPinned: chat.isPinned, + })) + return { chats, isLoading: query.isPending } +} diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-workspaces.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-workspaces.ts new file mode 100644 index 00000000000..6c54a696a36 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-workspaces.ts @@ -0,0 +1,14 @@ +import { useWorkspacesQuery } from '@/hooks/queries/workspace' + +/** + * The organization's workspaces the viewer belongs to, for the sidebar's + * Workspaces section. Read from the viewer's workspace list — the same query the + * workspace switcher uses — narrowed to those the organization owns. + */ +export function useOrganizationWorkspaces(organizationId: string) { + const { data = [], isLoading } = useWorkspacesQuery() + + const workspaces = data.filter((workspace) => workspace.organizationId === organizationId) + + return { workspaces, isLoading } +} diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/index.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/index.ts new file mode 100644 index 00000000000..9963f275118 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/index.ts @@ -0,0 +1 @@ +export { OrganizationSidebar } from './organization-sidebar' diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/navigation.ts b/apps/sim/app/o/[organizationId]/components/organization-sidebar/navigation.ts new file mode 100644 index 00000000000..9b3b0a6a6c6 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/navigation.ts @@ -0,0 +1,33 @@ +import { Home, Integration, Search } from '@sim/emcn/icons' +import { organizationRoutes } from '@/lib/navigation/paths' +import type { SidebarNavItemData } from '@/app/workspace/[workspaceId]/w/components/sidebar/components' + +type OrganizationNavRoute = 'home' | 'search' | 'integrations' + +interface OrganizationNavEntry { + id: string + label: string + icon: SidebarNavItemData['icon'] + route: OrganizationNavRoute +} + +/** + * The pinned block at the top of the organization sidebar, in display order. + * Hrefs are resolved per organization by {@link buildOrganizationNavItems}. + */ +const ORGANIZATION_NAV_ENTRIES: readonly OrganizationNavEntry[] = [ + { id: 'home', label: 'Home', icon: Home, route: 'home' }, + { id: 'search', label: 'Search', icon: Search, route: 'search' }, + { id: 'integrations', label: 'Integrations', icon: Integration, route: 'integrations' }, +] + +export function buildOrganizationNavItems( + organizationId: string, + searchAvailable: boolean +): SidebarNavItemData[] { + const routes = organizationRoutes(organizationId) + return ORGANIZATION_NAV_ENTRIES.filter(() => searchAvailable).map(({ route, ...entry }) => ({ + ...entry, + href: routes[route], + })) +} diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/organization-sidebar.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/organization-sidebar.tsx new file mode 100644 index 00000000000..8be529333e1 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/organization-sidebar.tsx @@ -0,0 +1,340 @@ +'use client' + +import { type ComponentProps, memo, useCallback, useRef, useState } from 'react' +import { Chip, cn, scrollFadeAttributes, scrollFadeClass, useScrollEdges } from '@sim/emcn' +import { PanelLeft } from '@sim/emcn/icons' +import { createLogger } from '@sim/logger' +import { usePathname } from 'next/navigation' +import { usePostHog } from 'posthog-js/react' +import { isMacPlatform } from '@/lib/core/utils/platform' +import { DOCS_URL, SLACK_COMMUNITY_URL } from '@/lib/help-links' +import { organizationRoutes } from '@/lib/navigation/paths' +import { captureEvent } from '@/lib/posthog/client' +import { + ChatsSection, + OrganizationFooter, + OrganizationHeader, + WorkspacesSection, +} from '@/app/o/[organizationId]/components/organization-sidebar/components' +import { + useCollapsedTooltips, + useOrganizationChats, +} from '@/app/o/[organizationId]/components/organization-sidebar/hooks' +import { buildOrganizationNavItems } from '@/app/o/[organizationId]/components/organization-sidebar/navigation' +import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' +import { OrganizationSettingsSidebar } from '@/app/o/[organizationId]/settings/organization-settings-sidebar' +import { useSidebarChrome } from '@/app/workspace/[workspaceId]/components/workspace-chrome' +import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider' +import { createCommands } from '@/app/workspace/[workspaceId]/utils/commands-utils' +import { + isNavItemActive, + NavItemContextMenu, + SidebarNavChip, + SidebarTooltip, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/components' +import { + SIDEBAR_DIVIDER_PAD_ABOVE_CLASS, + SIDEBAR_DIVIDER_PAD_BELOW_CLASS, + SIDEBAR_ITEM_GAP_CLASS, + SIDEBAR_SECTION_GAP_CLASS, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/constants' +import { useSidebarResize } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' +import { useContextMenu } from '@/hooks/use-context-menu' +import { useSidebarStore } from '@/stores/sidebar/store' + +const logger = createLogger('OrganizationSidebar') + +/** + * Opts a control out of the desktop shell's window-drag region. The header row is + * draggable chrome, so anything clickable inside it has to say so or the click is + * swallowed by the drag handler. + */ +const DRAG_EXEMPT_CLASS = '[-webkit-app-region:no-drag]' + +interface OrganizationChatsProps + extends Omit, 'chats' | 'isLoading'> { + organizationId: string +} + +function OrganizationChats({ organizationId, ...props }: OrganizationChatsProps) { + const { chats, isLoading } = useOrganizationChats(organizationId) + return +} + +/** + * The organization surface's rail: the same chrome as the workspace sidebar — + * header row, pinned nav block, a divided scroll region of sections, and the + * pinned footer — hosted by the same `WorkspaceChrome`, so collapse, resize, and + * the desktop hover-peek all behave identically. Collapse and peek state come from + * the chrome through {@link useSidebarChrome}. + */ +export const OrganizationSidebar = memo(function OrganizationSidebar() { + const { isCollapsed: railCollapsed, isPeeking } = useSidebarChrome() + /** The peek card always renders the expanded layout, whatever the rail's state. */ + const isCollapsed = railCollapsed && !isPeeking + + const scrollContainerRef = useRef(null) + const scrollContentRef = useRef(null) + + const pathname = usePathname() + const posthog = usePostHog() + const { organization, searchAccess } = useOrganizationContext() + const toggleCollapsed = useSidebarStore((state) => state.toggleCollapsed) + const { handlePointerDown } = useSidebarResize() + const showCollapsedTooltips = useCollapsedTooltips(isCollapsed) + const scrollEdges = useScrollEdges(scrollContainerRef, { + contentRef: scrollContentRef, + enabled: !isCollapsed, + }) + + const isMac = isMacPlatform() + const navItems = buildOrganizationNavItems(organization.id, searchAccess.memberScoped) + const settingsPath = organizationRoutes(organization.id).settings + const isSettings = pathname === settingsPath || pathname?.startsWith(`${settingsPath}/`) + + /** + * One menu serves every href-bearing row (nav items, workspaces, chats): the + * actions — open in a new tab, copy the link — only need the destination. + */ + const [menuHref, setMenuHref] = useState(null) + const { + isOpen: isHrefMenuOpen, + position: hrefMenuPosition, + menuRef: hrefMenuRef, + handleContextMenu: openHrefMenu, + closeMenu: closeHrefMenu, + } = useContextMenu() + + const handleHrefContextMenu = useCallback( + (e: React.MouseEvent, href: string) => { + setMenuHref(href) + openHrefMenu(e) + }, + [openHrefMenu] + ) + + /** Anchors the menu to the row's options button rather than the pointer. */ + const handleChatMoreClick = useCallback( + (e: React.MouseEvent, href: string) => { + if (isHrefMenuOpen) { + closeHrefMenu() + return + } + const rect = e.currentTarget.getBoundingClientRect() + setMenuHref(href) + openHrefMenu({ + preventDefault: () => {}, + stopPropagation: () => {}, + clientX: rect.right, + clientY: rect.top, + } as React.MouseEvent) + }, + [isHrefMenuOpen, closeHrefMenu, openHrefMenu] + ) + + const handleHrefMenuClose = () => { + closeHrefMenu() + setMenuHref(null) + } + + const handleOpenInNewTab = () => { + if (menuHref) window.open(menuHref, '_blank', 'noopener,noreferrer') + } + + const handleCopyLink = async () => { + if (!menuHref) return + try { + await navigator.clipboard.writeText(`${window.location.origin}${menuHref}`) + } catch (error) { + logger.error('Failed to copy link to clipboard', { error }) + } + } + + const handleOpenDocs = () => { + window.open(DOCS_URL, '_blank', 'noopener,noreferrer') + captureEvent(posthog, 'docs_opened', { source: 'help_menu' }) + } + + const handleOpenSlackCommunity = () => { + window.open(SLACK_COMMUNITY_URL, '_blank', 'noopener,noreferrer') + captureEvent(posthog, 'slack_community_opened', { source: 'help_menu' }) + } + + const handleEdgeKeyDown = (e: React.KeyboardEvent) => { + if (isCollapsed && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault() + toggleCollapsed() + } + } + + useRegisterGlobalCommands(() => + createCommands([ + { + id: 'toggle-sidebar', + handler: () => { + toggleCollapsed() + }, + }, + ]) + ) + + return ( +
+ + + {/* Not on the peek card: the resize hook writes an inline `--sidebar-width` that + out-specifies the `[data-peek]` rule, stranding the card at a stale width. */} + {!isPeeking && ( +
+ )} +
+ ) +}) diff --git a/apps/sim/app/o/[organizationId]/error.tsx b/apps/sim/app/o/[organizationId]/error.tsx new file mode 100644 index 00000000000..42dca68dcb2 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/error.tsx @@ -0,0 +1,18 @@ +'use client' + +import { + type ErrorBoundaryProps, + ErrorState, +} from '@/app/workspace/[workspaceId]/components/error/error' + +export default function OrganizationError({ error, reset }: ErrorBoundaryProps) { + return ( + + ) +} diff --git a/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx b/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx new file mode 100644 index 00000000000..e0992209015 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx @@ -0,0 +1,100 @@ +'use client' + +import { Button, cn } from '@sim/emcn' +import { ArrowUp } from '@sim/emcn/icons' +import { useAnimatedPlaceholder } from '@/hooks/use-animated-placeholder' + +const SEND_BUTTON_BASE = 'size-[28px] rounded-full border-0 p-0 transition-colors' +const SEND_BUTTON_ACTIVE = + 'bg-[#383838] hover:bg-[#575757] dark:bg-[#E0E0E0] dark:hover:bg-[#CFCFCF]' +const SEND_BUTTON_DISABLED = 'bg-[#808080] dark:bg-[#808080]' + +interface ComposerProps { + value: string + /** On the empty home the placeholder types itself and the field is taller; in a chat it is the plain footer input. */ + isInitialView: boolean + isSending: boolean + onChange: (value: string) => void + onSubmit: () => void + onStop: () => void +} + +/** + * The organization home composer: a question to the Assistant. Wears the + * workspace chat input's chrome — the framed field and the send control — and + * carries only the controls that are wired for the organization. + */ +export function Composer({ + value, + isInitialView, + isSending, + onChange, + onSubmit, + onStop, +}: ComposerProps) { + const canSubmit = value.trim().length > 0 + const animatedPlaceholder = useAnimatedPlaceholder(isInitialView) + const placeholder = isInitialView ? animatedPlaceholder : 'Send message to Sim' + + return ( +
+
+