improvement(self-host): simplify capability setup configuration (#6230)

* feat(self-host): add capability-aware setup

* fix(self-host): preserve capability compatibility

* fix(copilot): honor preview availability server-side

* improvement(self-host): centralize capability resolution

* fix(self-host): preserve integration availability paths

* fix(testing): align capability-aware config mocks

* improvement(self-host): simplify capability setup configuration

* fix(setup): preserve unowned storage overrides

* fix(self-host): reconcile storage and allowlists

* fix(integrations): preserve connect deep links
This commit is contained in:
Theodore Li
2026-08-04 12:47:36 -07:00
committed by GitHub
parent 39c3fe60d9
commit 35fd4ef42f
135 changed files with 11065 additions and 1060 deletions
+46 -2
View File
@@ -163,6 +163,32 @@ export const {ServiceName}Block: BlockConfig = {
Optional companions: `credentialLabels` (override the picker's section/connect-row copy) and `allowServiceAccounts: true` (trigger-mode only — list service accounts, which triggers otherwise exclude; set only when the trigger's polling path can resolve a service-account token). The connect modal, provider families (Google JSON key, Atlassian token, token-paste, client-credential, Slack bot), and the preview gate are all resolved from `serviceAccountProviderId` — you don't wire them per block.
### OAuth deployment availability (required for integration blocks)
A visible tools-category block with OAuth is deployment-gated. Its `oauth-input.serviceId` is
projected into `apps/sim/lib/integrations/integrations.json`, then resolved through
`resolveOAuthClientCapabilityId()` in `apps/sim/lib/core/config/env-capabilities.ts`.
When adding or changing an OAuth integration block:
1. Keep exactly one distinct OAuth `serviceId` across the block's `oauth-input` subBlocks.
2. Confirm that service ID resolves to an entry in `OAUTH_CLIENT_CAPABILITIES`. Google and
Microsoft service IDs intentionally share their provider-level capability; do not add duplicate
entries for those aliases.
3. For a new capability, add its required client fields to `OAUTH_CLIENT_CAPABILITIES` and ensure
every referenced field exists in the env schema in `apps/sim/lib/core/config/env.ts`. Then add
the matching `text` or `secret` input modes to `OAUTH_CLIENT_SETUP_FIELDS` in
`scripts/setup/capability-config.ts`. The CLI catalog is exhaustively typed and checked against
the runtime field list; do not infer secrecy from the field name.
4. If the canonical OAuth service declares `serviceAccountProviderId`, keep
`SERVICE_ACCOUNT_METADATA_BY_OAUTH_SERVICE_ID` in
`apps/sim/lib/integrations/service-account-metadata.ts` aligned. Set
`deploymentRequirement` only when the service-account path is preview-gated or depends on the
OAuth client fields; otherwise omit it.
Missing capability metadata is a runtime configuration error, not a reason to make the integration
silently available.
### Selectors (with dynamic options)
```typescript
// Channel selector (Slack, Discord, etc.)
@@ -919,12 +945,25 @@ Derive templates from the service's real use cases. Each prompt should name a co
- **Ground every skill in operations the block actually exposes** — cross-check each skill's steps against `tools.access`. Never describe an action the integration cannot perform.
- **Derive skills from real, popular use cases found online — never invent them.** Web-search the service's documented use cases (vendor use-case/solutions pages, official docs describing the workflow, reputable "top automations for X" articles) and only add a skill you can source as something people genuinely do with the service. Do not hallucinate skills.
## Generated tool metadata
## Generated artifacts
Adding a block on its own needs **no** regeneration — a block references existing tool IDs through `tools.access` and does not change any tool's shape.
Adding a block on its own needs no **tool metadata** regeneration — a block references existing
tool IDs through `tools.access` and does not change any tool's shape.
But if the same change also adds, edits **or removes** a tool, run `bun run tool-metadata:generate` and commit the result, or CI fails on stale artifacts. That matters here because a block's `outputs` are authored to match its tools' outputs, and the UI now reads those from the generated metadata rather than the executable registry — an unregenerated tool change makes the block's outputs disagree with what the panel renders. See `.agents/skills/tool-registry-boundary/SKILL.md`.
A visible integration block does require the generated integration catalog and docs to be refreshed.
After adding or changing one, run:
```bash
bun run scripts/generate-docs.ts
bun run integration-catalog:check
```
The catalog check independently derives deployment metadata from the executable block registry and
compares it with the committed `apps/sim/lib/integrations/integrations.json`. Review the generated
diff and keep only intentional changes.
## Checklist Before Finishing
- [ ] `integrationType` is set to the correct `IntegrationType` enum value
@@ -934,12 +973,17 @@ But if the same change also adds, edits **or removes** a tool, run `bun run tool
- [ ] DependsOn set for fields that need other values
- [ ] Required fields marked correctly (boolean or condition)
- [ ] OAuth inputs have correct `serviceId` and `requiredScopes: getScopesForService(serviceId)`
- [ ] Every OAuth `serviceId` resolves through `resolveOAuthClientCapabilityId()` to the correct `OAUTH_CLIENT_CAPABILITIES` entry
- [ ] Any new OAuth capability fields exist in `apps/sim/lib/core/config/env.ts`
- [ ] If the OAuth service supports service accounts, `SERVICE_ACCOUNT_METADATA_BY_OAUTH_SERVICE_ID` matches its canonical `serviceAccountProviderId` and deployment requirement
- [ ] Scope descriptions added to `SCOPE_DESCRIPTIONS` in `lib/oauth/utils.ts` for any new scopes
- [ ] Tools.access lists all tool IDs (snake_case)
- [ ] Tools.config.tool returns correct tool ID (snake_case)
- [ ] Outputs match tool outputs
- [ ] Block + meta registered in registry-maps.ts (`BLOCK_REGISTRY` / `BLOCK_META_REGISTRY`)
- [ ] If any tool was added, changed or removed alongside the block: ran `bun run tool-metadata:generate` and committed the artifacts
- [ ] Ran `bun run scripts/generate-docs.ts`, reviewed the generated diff, and committed the integration catalog changes
- [ ] `bun run integration-catalog:check` passes
- [ ] If icon missing: asked user to provide SVG
- [ ] If triggers exist: `triggers` config set, trigger subBlocks spread
- [ ] Optional/rarely-used fields set to `mode: 'advanced'`
+47 -2
View File
@@ -17,7 +17,8 @@ Adding an integration involves these steps in order:
4. **Add Icon** - Add the service's brand icon
5. **Create Triggers** (optional) - If the service supports webhooks
6. **Register** - Register tools, block, and triggers in their registries
7. **Generate Docs** - Run the docs generation script
7. **Configure Deployment Availability** - Wire OAuth client and service-account metadata
8. **Generate and Validate the Catalog** - Regenerate docs/catalog artifacts and run drift checks
## Step 1: Research the API
@@ -465,15 +466,48 @@ export const TRIGGER_REGISTRY: TriggerRegistry = {
}
```
## Step 7: Generate Docs
## Step 7: Configure Deployment Availability
Do this for every visible OAuth integration. API-key and unauthenticated integrations do not need
an OAuth client capability.
The block's `oauth-input.serviceId` is the canonical link between the generated integration catalog,
the OAuth service configuration, deployment availability, and the setup CLI.
1. Ensure the block has exactly one distinct OAuth `serviceId` and that it matches the canonical
service entry in `apps/sim/lib/oauth/oauth.ts`.
2. Confirm `resolveOAuthClientCapabilityId(serviceId)` resolves to the intended provider entry in
`OAUTH_CLIENT_CAPABILITIES` in `apps/sim/lib/core/config/env-capabilities.ts`. Google and
Microsoft service IDs deliberately share provider-level capabilities.
3. For a new OAuth provider, add the required client fields to `OAUTH_CLIENT_CAPABILITIES`, add
every referenced field to the env schema in `apps/sim/lib/core/config/env.ts`, and add the
matching `text` or `secret` entries to `OAUTH_CLIENT_SETUP_FIELDS` in
`scripts/setup/capability-config.ts`. Do not create integration-specific setup logic or infer
secret fields from naming; the CLI mapping is exhaustively checked against the runtime fields.
4. If the canonical OAuth service has `serviceAccountProviderId`, add the matching projection to
`SERVICE_ACCOUNT_METADATA_BY_OAUTH_SERVICE_ID` in
`apps/sim/lib/integrations/service-account-metadata.ts`. Use:
- no `deploymentRequirement` when the service-account path works independently of OAuth client fields;
- `'oauth-client'` when it requires the same deployment OAuth client fields;
- `'preview-gated'` when availability is controlled by the service-account preview block.
Never add a permissive fallback for missing capability metadata. A visible OAuth integration without
a resolvable capability must fail validation.
## Step 8: Generate and Validate the Catalog
Run the documentation generator:
```bash
bun run scripts/generate-docs.ts
bun run integration-catalog:check
```
This creates `apps/docs/content/docs/en/integrations/{service}.mdx` — one page per service carrying the block's Actions and, if it has one, its Triggers section. Never hand-edit generated pages; the only editable region is the `{/* MANUAL-CONTENT */}` block (see `scripts/README.md`).
The same generator refreshes `apps/sim/lib/integrations/integrations.json`. The catalog check then
derives the deployment-relevant fields from the executable block registry and compares them with the
committed projection. Review the generated diff and keep only intentional changes.
## V2 Integration Pattern
If creating V2 versions (API-aligned outputs):
@@ -524,6 +558,13 @@ If creating V2 versions (API-aligned outputs):
- [ ] Used `getCanonicalScopesForProvider()` in `auth.ts` (never hardcode)
- [ ] Used `getScopesForService()` in block `requiredScopes` (never hardcode)
### Deployment Availability (if OAuth service)
- [ ] Block declares exactly one distinct `oauth-input.serviceId`
- [ ] `resolveOAuthClientCapabilityId(serviceId)` resolves to the intended `OAUTH_CLIENT_CAPABILITIES` entry
- [ ] Every new OAuth capability field exists in `apps/sim/lib/core/config/env.ts`
- [ ] Runtime OAuth fields live in `OAUTH_CLIENT_CAPABILITIES`; matching CLI input modes live in the exhaustively checked `OAUTH_CLIENT_SETUP_FIELDS`
- [ ] If `serviceAccountProviderId` is configured, `SERVICE_ACCOUNT_METADATA_BY_OAUTH_SERVICE_ID` has the matching projection and deployment requirement
### Icon
- [ ] Asked user to provide SVG
- [ ] Added icon to `components/icons.tsx`
@@ -542,6 +583,8 @@ If creating V2 versions (API-aligned outputs):
### Docs
- [ ] Ran `bun run scripts/generate-docs.ts`
- [ ] Verified docs file created
- [ ] Reviewed and committed the generated `apps/sim/lib/integrations/integrations.json` change
- [ ] `bun run integration-catalog:check` passes
### Final Validation (Required)
- [ ] Read every tool file and cross-referenced inputs/outputs against the API docs
@@ -886,3 +929,5 @@ requiredScopes: getScopesForService('{service}'),
10. **Complex inputs need wandConfig** - Timestamps, JSON arrays, and other hard-to-type values should have `wandConfig` enabled
11. **Never hardcode scopes** - Use `getScopesForService()` in blocks and `getCanonicalScopesForProvider()` in auth.ts
12. **Always add scope descriptions** - New scopes must have entries in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts`
13. **OAuth service IDs need deployment capabilities** - Every visible OAuth integration must resolve through `OAUTH_CLIENT_CAPABILITIES`; shared Google/Microsoft aliases map to their provider capability
14. **Keep runtime and presentation separate** - Runtime OAuth fields live in `env-capabilities.ts`; CLI input modes live in the exhaustively checked `scripts/setup/capability-config.ts` mapping
+10 -5
View File
@@ -54,17 +54,23 @@ When the user runs `/ship`:
```
Then `git status --short` to see what regenerated — those files must be staged in step 7 alongside your own changes.
**Do NOT blanket-run the domain generators here.** `mship:generate` (`generate-mship-contracts.ts`) is an **umbrella** that drives all nine mothership contract generators (`mship-contracts`, `billing-protocol-contract`, `mship-tools`, the four `trace-*`, `metrics-contract`, `vfs-snapshot-contract`) and biome-formats `apps/sim/lib/copilot/generated/` — never run it *and* its constituents (they write the same files and corrupt each other in parallel), and never run it on an ordinary ship: it reads an **external** copilot-contract source that isn't checked out in most worktrees, so it hard-fails with `ENOENT` and would abort ship for an unrelated reason. `generate:pi-model-catalog` (under `apps/sim`) likewise regenerates from the installed Pi package, not repo source. Only when **this PR's diff actually touches** a domain generator's input do you regenerate it deliberately and run its matching `:check` (`bun run mship:check` / the individual `*:check`) — with the external source present.
**Do NOT blanket-run the domain generators here.** `mship:generate` (`generate-mship-contracts.ts`) is an **umbrella** that drives all nine mothership contract generators (`mship-contracts`, `billing-protocol-contract`, `mship-tools`, the four `trace-*`, `metrics-contract`, `vfs-snapshot-contract`) and biome-formats `apps/sim/lib/copilot/generated/` — never run it *and* its constituents (they write the same files and corrupt each other in parallel), and never run it on an ordinary ship: it reads an **external** copilot-contract source that isn't checked out in most worktrees, so it hard-fails with `ENOENT` and would abort ship for an unrelated reason. `generate:pi-model-catalog` (under `apps/sim`) likewise regenerates from the installed Pi package, not repo source. `scripts/generate-docs.ts` rewrites the integration docs and client-safe catalog; run it when this PR changes their block/icon/landing-content inputs or when `integration-catalog:check` reports drift, then review its broad generated diff. Only when **this PR's diff actually touches** a domain generator's input do you regenerate it deliberately and run its matching `:check` (`bun run mship:check` / the individual `*:check`) — with the external source present.
**Phase B — run lint + every audit CI enforces, in parallel, and abort ship if any fails.** `bun run lint` first (it autofixes formatting and mutates files, so don't parallelize it with the read-only audits), then fan the rest out and collect exit codes. This is exactly the read-only audit set from CI's `Lint and Test` job (all in-repo, runnable in any worktree):
**Phase B — run lint + every audit CI enforces, in parallel, and abort ship if any fails.** Before running the commands, compare this list with `.github/workflows/test-build.yml`; when CI adds an audit, run it and update this skill instead of trusting a stale snapshot. The env-flag audit is currently an inline workflow block rather than a package script: when `apps/sim/lib/core/config/env-flags.ts` changed, run that current workflow block verbatim instead of copying a second version into this skill. Run `bun run lint` first (it autofixes formatting and mutates files, so don't parallelize it with the read-only audits), then run the base-sensitive block-registry check, then fan the independent audits out and collect exit codes:
```bash
# autofix formatting first (mutating; not parallel-safe with the audits). Gate its exit too —
# a non-zero lint (unfixable errors) must abort before the audits run, not be ignored.
bun run lint || { echo "❌ lint failed — do not ship"; exit 1; }
bun run apps/sim/scripts/check-block-registry.ts origin/staging || {
echo "❌ block registry audit failed — do not ship"
exit 1
}
rm -f /tmp/ship-audit-results
for s in check:boundaries check:api-validation:strict check:utils check:zustand-v5 \
for s in check:boundaries check:api-validation:strict check:desktop-bridge check:desktop-ipc \
check:utils check:zustand-v5 \
check:react-query check:client-boundary check:bare-icons check:icon-paths \
check:realtime-prune skills:check agent-stream-docs:check; do
check:realtime-prune check:tool-registry-boundary tool-metadata:check \
integration-catalog:check skills:check agent-stream-docs:check; do
( bun run "$s" >"/tmp/ship-audit-${s//:/-}.log" 2>&1; echo "$? $s" >>/tmp/ship-audit-results ) &
done
wait
@@ -150,4 +156,3 @@ gh pr create --base staging --title "COMMIT_MESSAGE" --body "PR_BODY"
- "Tested manually" is acceptable for testing section; include lint, boundary validation, and (when migrations changed) `check:migrations` results when run
- Checkboxes filled in appropriately
- No screenshots section unless UI changes
+45 -7
View File
@@ -30,6 +30,11 @@ apps/sim/components/icons.tsx # Icon definition
apps/sim/lib/auth/auth.ts # OAuth config — should use getCanonicalScopesForProvider()
apps/sim/lib/oauth/oauth.ts # OAuth provider config — single source of truth for scopes
apps/sim/lib/oauth/utils.ts # Scope utilities, SCOPE_DESCRIPTIONS for modal UI
apps/sim/lib/core/config/env-capabilities.ts # OAuth client runtime capability source of truth
apps/sim/lib/core/config/env.ts # Runtime env schema for capability fields
scripts/setup/capability-config.ts # Exhaustive CLI input-mode mapping for OAuth fields
apps/sim/lib/integrations/integrations.json # Generated client-safe integration catalog
apps/sim/lib/integrations/service-account-metadata.ts # Lightweight service-account projection
```
## Step 2: Pull API Documentation
@@ -233,7 +238,28 @@ Scopes are centralized — the single source of truth is `OAUTH_PROVIDERS` in `l
- [ ] Each scope has a human-readable description in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts`
- [ ] No excess scopes that aren't needed by any tool
## Step 6: Validate Pagination Consistency
## Step 6: Validate Deployment Availability (if OAuth service)
The deployment UI and setup CLI do not infer OAuth client fields from scopes. They resolve the
block's generated `oauthServiceId` through the application-owned capability catalog.
- [ ] The visible integration block has exactly one distinct `oauth-input.serviceId`
- [ ] `resolveOAuthClientCapabilityId(serviceId)` returns the intended provider capability
- [ ] The resolved provider exists in `OAUTH_CLIENT_CAPABILITIES`
- [ ] Every field listed by that capability exists in `apps/sim/lib/core/config/env.ts`
- [ ] Every capability field has the correct `text` or `secret` entry in `OAUTH_CLIENT_SETUP_FIELDS`; no CLI naming heuristic is required
- [ ] Shared Google/Microsoft service IDs resolve to their provider capability rather than duplicate entries
- [ ] `bun run setup integration <capabilityId>` is the command emitted by availability; the CLI has only the exhaustive input-mode projection, not a second runtime provider definition
- [ ] If the canonical OAuth service declares `serviceAccountProviderId`,
`SERVICE_ACCOUNT_METADATA_BY_OAUTH_SERVICE_ID[serviceId]` has the same provider ID
- [ ] The service-account `deploymentRequirement` matches how that credential actually works:
omitted for an independent path, `'oauth-client'` when it needs the OAuth client fields, or
`'preview-gated'` when controlled by a preview block
Treat a missing capability as **critical**: runtime availability intentionally throws instead of
silently exposing an unusable integration.
## Step 7: Validate Pagination Consistency
If any tools support pagination:
- [ ] Pagination param names match the API docs (e.g., `pagination_token` vs `next_token` vs `cursor`)
@@ -241,7 +267,7 @@ If any tools support pagination:
- [ ] Pagination response fields (`nextToken`, `cursor`, etc.) are included in tool outputs
- [ ] Pagination subBlocks are set to `mode: 'advanced'`
## Step 7: Validate Memory Load Safety
## Step 8: Validate Memory Load Safety
If any tool lists, searches, exports, imports, downloads, uploads, paginates, batches, transforms arrays, or reads file/HTTP bodies, read `.agents/skills/memory-load-check/SKILL.md` and apply it to the integration.
@@ -251,13 +277,13 @@ If any tool lists, searches, exports, imports, downloads, uploads, paginates, ba
- [ ] Large result payloads are summarized, paginated, referenced, or capped rather than raw-dumped
- [ ] Pagination and download tests cover caps, early stop behavior, or partial-result preservation when relevant
## Step 8: Validate Error Handling
## Step 9: Validate Error Handling
- [ ] `transformResponse` checks for error conditions before accessing data
- [ ] Error responses include meaningful messages (not just generic "failed")
- [ ] HTTP error status codes are handled (check `response.ok` or status codes)
## Step 9: Report and Fix
## Step 10: Report and Fix
### Report Format
@@ -270,6 +296,9 @@ Group findings by severity:
- Missing error handling that would cause crashes
- Tool ID mismatch between tool file, registry, and block `tools.access`
- OAuth scopes missing in `auth.ts` that tools need
- OAuth integration `serviceId` missing from the deployment capability catalog
- Capability references an env field absent from the runtime env schema
- Service-account metadata disagrees with the canonical OAuth service configuration
- `tools.config.tool` returning wrong tool ID for an operation
- Type coercions in `tools.config.tool` instead of `tools.config.params`
@@ -301,11 +330,15 @@ Several files are generated from tool and block definitions. Editing a tool or b
```bash
bun run tool-metadata:generate # repo root — apps/sim/tools/generated/*
cd apps/sim && bun run generate-docs # docs .mdx + lib/integrations/integrations.json + docs icons
bun run scripts/generate-docs.ts # docs .mdx + lib/integrations/integrations.json + docs icons
bun run integration-catalog:check # registry ↔ committed deployment metadata drift
```
- **`tool-metadata:generate`** — required whenever a tool's `outputs`, `params`, or descriptions change. CI enforces this with `bun run tool-metadata:check`, which fails with *"Generated tool metadata is stale"*. This is the easiest gate to miss, because nothing in the tool file hints that a generated artifact mirrors it.
- **`generate-docs`** — required whenever block metadata changes (`bgColor`, `name`, `description`, operations, outputs). Regenerates the integration `.mdx`, `integrations.json`, and the docs copy of `components/icons.tsx`.
- **`integration-catalog:check`** — loads the executable block registry, derives visible integration
deployment fields, and compares them with the committed catalog. It catches missing/unexpected
entries and stale auth/service IDs without loading the executable registry in client code.
**Always diff the regen output before committing.** These generators rewrite every file they own, so they will also sweep in unrelated drift that accumulated on the base branch — pages losing sections, unrelated icons appearing. Keep only the hunks belonging to the integration under validation and `git checkout --` the rest, otherwise an unrelated doc regression rides along in the PR. Verify no page was silently dropped by comparing the directory listing before and after.
@@ -318,8 +351,10 @@ After fixing, confirm:
2. TypeScript compiles clean (no type errors) — check the error list is empty for the files you touched; pre-existing unrelated errors in a worktree usually mean workspace packages resolve to the main checkout
3. The integration's tests pass, and any test you added actually fails without its fix (revert it once and watch it go red)
4. Derived artifacts regenerated and their diffs reviewed (see above)
5. Re-read all modified files to verify fixes are correct
6. Any remaining unknown response schemas were explicitly reported to the user instead of guessed
5. `bun run integration-catalog:check` passes
6. For OAuth or service-account changes, `bun test apps/sim/lib/integrations/availability.server.test.ts` passes
7. Re-read all modified files to verify fixes are correct
8. Any remaining unknown response schemas were explicitly reported to the user instead of guessed
## Checklist Summary
@@ -333,6 +368,9 @@ After fixing, confirm:
- [ ] Validated block outputs match what tools return, with typed JSON where possible
- [ ] Validated OAuth scopes use centralized utilities (getScopesForService, getCanonicalScopesForProvider) — no hardcoded arrays
- [ ] Validated scope descriptions exist in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts` for all scopes
- [ ] Validated OAuth `serviceId` resolves to the intended `OAUTH_CLIENT_CAPABILITIES` entry and all capability fields exist in the env schema
- [ ] Validated service-account projection and deployment requirement against the canonical OAuth service config
- [ ] Regenerated `integrations.json` when block metadata changed and ran `bun run integration-catalog:check`
- [ ] Validated pagination consistency across tools and block
- [ ] Validated memory load safety using `.agents/skills/memory-load-check/SKILL.md` when tools list/search/download/import/export/batch data
- [ ] Validated error handling (error checks, meaningful messages)
+46 -2
View File
@@ -162,6 +162,32 @@ export const {ServiceName}Block: BlockConfig = {
Optional companions: `credentialLabels` (override the picker's section/connect-row copy) and `allowServiceAccounts: true` (trigger-mode only — list service accounts, which triggers otherwise exclude; set only when the trigger's polling path can resolve a service-account token). The connect modal, provider families (Google JSON key, Atlassian token, token-paste, client-credential, Slack bot), and the preview gate are all resolved from `serviceAccountProviderId` — you don't wire them per block.
### OAuth deployment availability (required for integration blocks)
A visible tools-category block with OAuth is deployment-gated. Its `oauth-input.serviceId` is
projected into `apps/sim/lib/integrations/integrations.json`, then resolved through
`resolveOAuthClientCapabilityId()` in `apps/sim/lib/core/config/env-capabilities.ts`.
When adding or changing an OAuth integration block:
1. Keep exactly one distinct OAuth `serviceId` across the block's `oauth-input` subBlocks.
2. Confirm that service ID resolves to an entry in `OAUTH_CLIENT_CAPABILITIES`. Google and
Microsoft service IDs intentionally share their provider-level capability; do not add duplicate
entries for those aliases.
3. For a new capability, add its required client fields to `OAUTH_CLIENT_CAPABILITIES` and ensure
every referenced field exists in the env schema in `apps/sim/lib/core/config/env.ts`. Then add
the matching `text` or `secret` input modes to `OAUTH_CLIENT_SETUP_FIELDS` in
`scripts/setup/capability-config.ts`. The CLI catalog is exhaustively typed and checked against
the runtime field list; do not infer secrecy from the field name.
4. If the canonical OAuth service declares `serviceAccountProviderId`, keep
`SERVICE_ACCOUNT_METADATA_BY_OAUTH_SERVICE_ID` in
`apps/sim/lib/integrations/service-account-metadata.ts` aligned. Set
`deploymentRequirement` only when the service-account path is preview-gated or depends on the
OAuth client fields; otherwise omit it.
Missing capability metadata is a runtime configuration error, not a reason to make the integration
silently available.
### Selectors (with dynamic options)
```typescript
// Channel selector (Slack, Discord, etc.)
@@ -918,12 +944,25 @@ Derive templates from the service's real use cases. Each prompt should name a co
- **Ground every skill in operations the block actually exposes** — cross-check each skill's steps against `tools.access`. Never describe an action the integration cannot perform.
- **Derive skills from real, popular use cases found online — never invent them.** Web-search the service's documented use cases (vendor use-case/solutions pages, official docs describing the workflow, reputable "top automations for X" articles) and only add a skill you can source as something people genuinely do with the service. Do not hallucinate skills.
## Generated tool metadata
## Generated artifacts
Adding a block on its own needs **no** regeneration — a block references existing tool IDs through `tools.access` and does not change any tool's shape.
Adding a block on its own needs no **tool metadata** regeneration — a block references existing
tool IDs through `tools.access` and does not change any tool's shape.
But if the same change also adds, edits **or removes** a tool, run `bun run tool-metadata:generate` and commit the result, or CI fails on stale artifacts. That matters here because a block's `outputs` are authored to match its tools' outputs, and the UI now reads those from the generated metadata rather than the executable registry — an unregenerated tool change makes the block's outputs disagree with what the panel renders. See `.agents/skills/tool-registry-boundary/SKILL.md`.
A visible integration block does require the generated integration catalog and docs to be refreshed.
After adding or changing one, run:
```bash
bun run scripts/generate-docs.ts
bun run integration-catalog:check
```
The catalog check independently derives deployment metadata from the executable block registry and
compares it with the committed `apps/sim/lib/integrations/integrations.json`. Review the generated
diff and keep only intentional changes.
## Checklist Before Finishing
- [ ] `integrationType` is set to the correct `IntegrationType` enum value
@@ -933,12 +972,17 @@ But if the same change also adds, edits **or removes** a tool, run `bun run tool
- [ ] DependsOn set for fields that need other values
- [ ] Required fields marked correctly (boolean or condition)
- [ ] OAuth inputs have correct `serviceId` and `requiredScopes: getScopesForService(serviceId)`
- [ ] Every OAuth `serviceId` resolves through `resolveOAuthClientCapabilityId()` to the correct `OAUTH_CLIENT_CAPABILITIES` entry
- [ ] Any new OAuth capability fields exist in `apps/sim/lib/core/config/env.ts`
- [ ] If the OAuth service supports service accounts, `SERVICE_ACCOUNT_METADATA_BY_OAUTH_SERVICE_ID` matches its canonical `serviceAccountProviderId` and deployment requirement
- [ ] Scope descriptions added to `SCOPE_DESCRIPTIONS` in `lib/oauth/utils.ts` for any new scopes
- [ ] Tools.access lists all tool IDs (snake_case)
- [ ] Tools.config.tool returns correct tool ID (snake_case)
- [ ] Outputs match tool outputs
- [ ] Block + meta registered in registry-maps.ts (`BLOCK_REGISTRY` / `BLOCK_META_REGISTRY`)
- [ ] If any tool was added, changed or removed alongside the block: ran `bun run tool-metadata:generate` and committed the artifacts
- [ ] Ran `bun run scripts/generate-docs.ts`, reviewed the generated diff, and committed the integration catalog changes
- [ ] `bun run integration-catalog:check` passes
- [ ] If icon missing: asked user to provide SVG
- [ ] If triggers exist: `triggers` config set, trigger subBlocks spread
- [ ] Optional/rarely-used fields set to `mode: 'advanced'`
+47 -2
View File
@@ -16,7 +16,8 @@ Adding an integration involves these steps in order:
4. **Add Icon** - Add the service's brand icon
5. **Create Triggers** (optional) - If the service supports webhooks
6. **Register** - Register tools, block, and triggers in their registries
7. **Generate Docs** - Run the docs generation script
7. **Configure Deployment Availability** - Wire OAuth client and service-account metadata
8. **Generate and Validate the Catalog** - Regenerate docs/catalog artifacts and run drift checks
## Step 1: Research the API
@@ -464,15 +465,48 @@ export const TRIGGER_REGISTRY: TriggerRegistry = {
}
```
## Step 7: Generate Docs
## Step 7: Configure Deployment Availability
Do this for every visible OAuth integration. API-key and unauthenticated integrations do not need
an OAuth client capability.
The block's `oauth-input.serviceId` is the canonical link between the generated integration catalog,
the OAuth service configuration, deployment availability, and the setup CLI.
1. Ensure the block has exactly one distinct OAuth `serviceId` and that it matches the canonical
service entry in `apps/sim/lib/oauth/oauth.ts`.
2. Confirm `resolveOAuthClientCapabilityId(serviceId)` resolves to the intended provider entry in
`OAUTH_CLIENT_CAPABILITIES` in `apps/sim/lib/core/config/env-capabilities.ts`. Google and
Microsoft service IDs deliberately share provider-level capabilities.
3. For a new OAuth provider, add the required client fields to `OAUTH_CLIENT_CAPABILITIES`, add
every referenced field to the env schema in `apps/sim/lib/core/config/env.ts`, and add the
matching `text` or `secret` entries to `OAUTH_CLIENT_SETUP_FIELDS` in
`scripts/setup/capability-config.ts`. Do not create integration-specific setup logic or infer
secret fields from naming; the CLI mapping is exhaustively checked against the runtime fields.
4. If the canonical OAuth service has `serviceAccountProviderId`, add the matching projection to
`SERVICE_ACCOUNT_METADATA_BY_OAUTH_SERVICE_ID` in
`apps/sim/lib/integrations/service-account-metadata.ts`. Use:
- no `deploymentRequirement` when the service-account path works independently of OAuth client fields;
- `'oauth-client'` when it requires the same deployment OAuth client fields;
- `'preview-gated'` when availability is controlled by the service-account preview block.
Never add a permissive fallback for missing capability metadata. A visible OAuth integration without
a resolvable capability must fail validation.
## Step 8: Generate and Validate the Catalog
Run the documentation generator:
```bash
bun run scripts/generate-docs.ts
bun run integration-catalog:check
```
This creates `apps/docs/content/docs/en/integrations/{service}.mdx` — one page per service carrying the block's Actions and, if it has one, its Triggers section. Never hand-edit generated pages; the only editable region is the `{/* MANUAL-CONTENT */}` block (see `scripts/README.md`).
The same generator refreshes `apps/sim/lib/integrations/integrations.json`. The catalog check then
derives the deployment-relevant fields from the executable block registry and compares them with the
committed projection. Review the generated diff and keep only intentional changes.
## V2 Integration Pattern
If creating V2 versions (API-aligned outputs):
@@ -523,6 +557,13 @@ If creating V2 versions (API-aligned outputs):
- [ ] Used `getCanonicalScopesForProvider()` in `auth.ts` (never hardcode)
- [ ] Used `getScopesForService()` in block `requiredScopes` (never hardcode)
### Deployment Availability (if OAuth service)
- [ ] Block declares exactly one distinct `oauth-input.serviceId`
- [ ] `resolveOAuthClientCapabilityId(serviceId)` resolves to the intended `OAUTH_CLIENT_CAPABILITIES` entry
- [ ] Every new OAuth capability field exists in `apps/sim/lib/core/config/env.ts`
- [ ] Runtime OAuth fields live in `OAUTH_CLIENT_CAPABILITIES`; matching CLI input modes live in the exhaustively checked `OAUTH_CLIENT_SETUP_FIELDS`
- [ ] If `serviceAccountProviderId` is configured, `SERVICE_ACCOUNT_METADATA_BY_OAUTH_SERVICE_ID` has the matching projection and deployment requirement
### Icon
- [ ] Asked user to provide SVG
- [ ] Added icon to `components/icons.tsx`
@@ -541,6 +582,8 @@ If creating V2 versions (API-aligned outputs):
### Docs
- [ ] Ran `bun run scripts/generate-docs.ts`
- [ ] Verified docs file created
- [ ] Reviewed and committed the generated `apps/sim/lib/integrations/integrations.json` change
- [ ] `bun run integration-catalog:check` passes
### Final Validation (Required)
- [ ] Read every tool file and cross-referenced inputs/outputs against the API docs
@@ -885,3 +928,5 @@ requiredScopes: getScopesForService('{service}'),
10. **Complex inputs need wandConfig** - Timestamps, JSON arrays, and other hard-to-type values should have `wandConfig` enabled
11. **Never hardcode scopes** - Use `getScopesForService()` in blocks and `getCanonicalScopesForProvider()` in auth.ts
12. **Always add scope descriptions** - New scopes must have entries in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts`
13. **OAuth service IDs need deployment capabilities** - Every visible OAuth integration must resolve through `OAUTH_CLIENT_CAPABILITIES`; shared Google/Microsoft aliases map to their provider capability
14. **Keep runtime and presentation separate** - Runtime OAuth fields live in `env-capabilities.ts`; CLI input modes live in the exhaustively checked `scripts/setup/capability-config.ts` mapping
+10 -4
View File
@@ -53,17 +53,23 @@ When the user runs `/ship`:
```
Then `git status --short` to see what regenerated — those files must be staged in step 7 alongside your own changes.
**Do NOT blanket-run the domain generators here.** `mship:generate` (`generate-mship-contracts.ts`) is an **umbrella** that drives all nine mothership contract generators (`mship-contracts`, `billing-protocol-contract`, `mship-tools`, the four `trace-*`, `metrics-contract`, `vfs-snapshot-contract`) and biome-formats `apps/sim/lib/copilot/generated/` — never run it *and* its constituents (they write the same files and corrupt each other in parallel), and never run it on an ordinary ship: it reads an **external** copilot-contract source that isn't checked out in most worktrees, so it hard-fails with `ENOENT` and would abort ship for an unrelated reason. `generate:pi-model-catalog` (under `apps/sim`) likewise regenerates from the installed Pi package, not repo source. Only when **this PR's diff actually touches** a domain generator's input do you regenerate it deliberately and run its matching `:check` (`bun run mship:check` / the individual `*:check`) — with the external source present.
**Do NOT blanket-run the domain generators here.** `mship:generate` (`generate-mship-contracts.ts`) is an **umbrella** that drives all nine mothership contract generators (`mship-contracts`, `billing-protocol-contract`, `mship-tools`, the four `trace-*`, `metrics-contract`, `vfs-snapshot-contract`) and biome-formats `apps/sim/lib/copilot/generated/` — never run it *and* its constituents (they write the same files and corrupt each other in parallel), and never run it on an ordinary ship: it reads an **external** copilot-contract source that isn't checked out in most worktrees, so it hard-fails with `ENOENT` and would abort ship for an unrelated reason. `generate:pi-model-catalog` (under `apps/sim`) likewise regenerates from the installed Pi package, not repo source. `scripts/generate-docs.ts` rewrites the integration docs and client-safe catalog; run it when this PR changes their block/icon/landing-content inputs or when `integration-catalog:check` reports drift, then review its broad generated diff. Only when **this PR's diff actually touches** a domain generator's input do you regenerate it deliberately and run its matching `:check` (`bun run mship:check` / the individual `*:check`) — with the external source present.
**Phase B — run lint + every audit CI enforces, in parallel, and abort ship if any fails.** `bun run lint` first (it autofixes formatting and mutates files, so don't parallelize it with the read-only audits), then fan the rest out and collect exit codes. This is exactly the read-only audit set from CI's `Lint and Test` job (all in-repo, runnable in any worktree):
**Phase B — run lint + every audit CI enforces, in parallel, and abort ship if any fails.** Before running the commands, compare this list with `.github/workflows/test-build.yml`; when CI adds an audit, run it and update this skill instead of trusting a stale snapshot. The env-flag audit is currently an inline workflow block rather than a package script: when `apps/sim/lib/core/config/env-flags.ts` changed, run that current workflow block verbatim instead of copying a second version into this skill. Run `bun run lint` first (it autofixes formatting and mutates files, so don't parallelize it with the read-only audits), then run the base-sensitive block-registry check, then fan the independent audits out and collect exit codes:
```bash
# autofix formatting first (mutating; not parallel-safe with the audits). Gate its exit too —
# a non-zero lint (unfixable errors) must abort before the audits run, not be ignored.
bun run lint || { echo "❌ lint failed — do not ship"; exit 1; }
bun run apps/sim/scripts/check-block-registry.ts origin/staging || {
echo "❌ block registry audit failed — do not ship"
exit 1
}
rm -f /tmp/ship-audit-results
for s in check:boundaries check:api-validation:strict check:utils check:zustand-v5 \
for s in check:boundaries check:api-validation:strict check:desktop-bridge check:desktop-ipc \
check:utils check:zustand-v5 \
check:react-query check:client-boundary check:bare-icons check:icon-paths \
check:realtime-prune skills:check agent-stream-docs:check; do
check:realtime-prune check:tool-registry-boundary tool-metadata:check \
integration-catalog:check skills:check agent-stream-docs:check; do
( bun run "$s" >"/tmp/ship-audit-${s//:/-}.log" 2>&1; echo "$? $s" >>/tmp/ship-audit-results ) &
done
wait
+45 -7
View File
@@ -29,6 +29,11 @@ apps/sim/components/icons.tsx # Icon definition
apps/sim/lib/auth/auth.ts # OAuth config — should use getCanonicalScopesForProvider()
apps/sim/lib/oauth/oauth.ts # OAuth provider config — single source of truth for scopes
apps/sim/lib/oauth/utils.ts # Scope utilities, SCOPE_DESCRIPTIONS for modal UI
apps/sim/lib/core/config/env-capabilities.ts # OAuth client runtime capability source of truth
apps/sim/lib/core/config/env.ts # Runtime env schema for capability fields
scripts/setup/capability-config.ts # Exhaustive CLI input-mode mapping for OAuth fields
apps/sim/lib/integrations/integrations.json # Generated client-safe integration catalog
apps/sim/lib/integrations/service-account-metadata.ts # Lightweight service-account projection
```
## Step 2: Pull API Documentation
@@ -232,7 +237,28 @@ Scopes are centralized — the single source of truth is `OAUTH_PROVIDERS` in `l
- [ ] Each scope has a human-readable description in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts`
- [ ] No excess scopes that aren't needed by any tool
## Step 6: Validate Pagination Consistency
## Step 6: Validate Deployment Availability (if OAuth service)
The deployment UI and setup CLI do not infer OAuth client fields from scopes. They resolve the
block's generated `oauthServiceId` through the application-owned capability catalog.
- [ ] The visible integration block has exactly one distinct `oauth-input.serviceId`
- [ ] `resolveOAuthClientCapabilityId(serviceId)` returns the intended provider capability
- [ ] The resolved provider exists in `OAUTH_CLIENT_CAPABILITIES`
- [ ] Every field listed by that capability exists in `apps/sim/lib/core/config/env.ts`
- [ ] Every capability field has the correct `text` or `secret` entry in `OAUTH_CLIENT_SETUP_FIELDS`; no CLI naming heuristic is required
- [ ] Shared Google/Microsoft service IDs resolve to their provider capability rather than duplicate entries
- [ ] `bun run setup integration <capabilityId>` is the command emitted by availability; the CLI has only the exhaustive input-mode projection, not a second runtime provider definition
- [ ] If the canonical OAuth service declares `serviceAccountProviderId`,
`SERVICE_ACCOUNT_METADATA_BY_OAUTH_SERVICE_ID[serviceId]` has the same provider ID
- [ ] The service-account `deploymentRequirement` matches how that credential actually works:
omitted for an independent path, `'oauth-client'` when it needs the OAuth client fields, or
`'preview-gated'` when controlled by a preview block
Treat a missing capability as **critical**: runtime availability intentionally throws instead of
silently exposing an unusable integration.
## Step 7: Validate Pagination Consistency
If any tools support pagination:
- [ ] Pagination param names match the API docs (e.g., `pagination_token` vs `next_token` vs `cursor`)
@@ -240,7 +266,7 @@ If any tools support pagination:
- [ ] Pagination response fields (`nextToken`, `cursor`, etc.) are included in tool outputs
- [ ] Pagination subBlocks are set to `mode: 'advanced'`
## Step 7: Validate Memory Load Safety
## Step 8: Validate Memory Load Safety
If any tool lists, searches, exports, imports, downloads, uploads, paginates, batches, transforms arrays, or reads file/HTTP bodies, read `.agents/skills/memory-load-check/SKILL.md` and apply it to the integration.
@@ -250,13 +276,13 @@ If any tool lists, searches, exports, imports, downloads, uploads, paginates, ba
- [ ] Large result payloads are summarized, paginated, referenced, or capped rather than raw-dumped
- [ ] Pagination and download tests cover caps, early stop behavior, or partial-result preservation when relevant
## Step 8: Validate Error Handling
## Step 9: Validate Error Handling
- [ ] `transformResponse` checks for error conditions before accessing data
- [ ] Error responses include meaningful messages (not just generic "failed")
- [ ] HTTP error status codes are handled (check `response.ok` or status codes)
## Step 9: Report and Fix
## Step 10: Report and Fix
### Report Format
@@ -269,6 +295,9 @@ Group findings by severity:
- Missing error handling that would cause crashes
- Tool ID mismatch between tool file, registry, and block `tools.access`
- OAuth scopes missing in `auth.ts` that tools need
- OAuth integration `serviceId` missing from the deployment capability catalog
- Capability references an env field absent from the runtime env schema
- Service-account metadata disagrees with the canonical OAuth service configuration
- `tools.config.tool` returning wrong tool ID for an operation
- Type coercions in `tools.config.tool` instead of `tools.config.params`
@@ -300,11 +329,15 @@ Several files are generated from tool and block definitions. Editing a tool or b
```bash
bun run tool-metadata:generate # repo root — apps/sim/tools/generated/*
cd apps/sim && bun run generate-docs # docs .mdx + lib/integrations/integrations.json + docs icons
bun run scripts/generate-docs.ts # docs .mdx + lib/integrations/integrations.json + docs icons
bun run integration-catalog:check # registry ↔ committed deployment metadata drift
```
- **`tool-metadata:generate`** — required whenever a tool's `outputs`, `params`, or descriptions change. CI enforces this with `bun run tool-metadata:check`, which fails with *"Generated tool metadata is stale"*. This is the easiest gate to miss, because nothing in the tool file hints that a generated artifact mirrors it.
- **`generate-docs`** — required whenever block metadata changes (`bgColor`, `name`, `description`, operations, outputs). Regenerates the integration `.mdx`, `integrations.json`, and the docs copy of `components/icons.tsx`.
- **`integration-catalog:check`** — loads the executable block registry, derives visible integration
deployment fields, and compares them with the committed catalog. It catches missing/unexpected
entries and stale auth/service IDs without loading the executable registry in client code.
**Always diff the regen output before committing.** These generators rewrite every file they own, so they will also sweep in unrelated drift that accumulated on the base branch — pages losing sections, unrelated icons appearing. Keep only the hunks belonging to the integration under validation and `git checkout --` the rest, otherwise an unrelated doc regression rides along in the PR. Verify no page was silently dropped by comparing the directory listing before and after.
@@ -317,8 +350,10 @@ After fixing, confirm:
2. TypeScript compiles clean (no type errors) — check the error list is empty for the files you touched; pre-existing unrelated errors in a worktree usually mean workspace packages resolve to the main checkout
3. The integration's tests pass, and any test you added actually fails without its fix (revert it once and watch it go red)
4. Derived artifacts regenerated and their diffs reviewed (see above)
5. Re-read all modified files to verify fixes are correct
6. Any remaining unknown response schemas were explicitly reported to the user instead of guessed
5. `bun run integration-catalog:check` passes
6. For OAuth or service-account changes, `bun test apps/sim/lib/integrations/availability.server.test.ts` passes
7. Re-read all modified files to verify fixes are correct
8. Any remaining unknown response schemas were explicitly reported to the user instead of guessed
## Checklist Summary
@@ -332,6 +367,9 @@ After fixing, confirm:
- [ ] Validated block outputs match what tools return, with typed JSON where possible
- [ ] Validated OAuth scopes use centralized utilities (getScopesForService, getCanonicalScopesForProvider) — no hardcoded arrays
- [ ] Validated scope descriptions exist in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts` for all scopes
- [ ] Validated OAuth `serviceId` resolves to the intended `OAUTH_CLIENT_CAPABILITIES` entry and all capability fields exist in the env schema
- [ ] Validated service-account projection and deployment requirement against the canonical OAuth service config
- [ ] Regenerated `integrations.json` when block metadata changed and ran `bun run integration-catalog:check`
- [ ] Validated pagination consistency across tools and block
- [ ] Validated memory load safety using `.agents/skills/memory-load-check/SKILL.md` when tools list/search/download/import/export/batch data
- [ ] Validated error handling (error checks, meaningful messages)
+46 -2
View File
@@ -157,6 +157,32 @@ export const {ServiceName}Block: BlockConfig = {
Optional companions: `credentialLabels` (override the picker's section/connect-row copy) and `allowServiceAccounts: true` (trigger-mode only — list service accounts, which triggers otherwise exclude; set only when the trigger's polling path can resolve a service-account token). The connect modal, provider families (Google JSON key, Atlassian token, token-paste, client-credential, Slack bot), and the preview gate are all resolved from `serviceAccountProviderId` — you don't wire them per block.
### OAuth deployment availability (required for integration blocks)
A visible tools-category block with OAuth is deployment-gated. Its `oauth-input.serviceId` is
projected into `apps/sim/lib/integrations/integrations.json`, then resolved through
`resolveOAuthClientCapabilityId()` in `apps/sim/lib/core/config/env-capabilities.ts`.
When adding or changing an OAuth integration block:
1. Keep exactly one distinct OAuth `serviceId` across the block's `oauth-input` subBlocks.
2. Confirm that service ID resolves to an entry in `OAUTH_CLIENT_CAPABILITIES`. Google and
Microsoft service IDs intentionally share their provider-level capability; do not add duplicate
entries for those aliases.
3. For a new capability, add its required client fields to `OAUTH_CLIENT_CAPABILITIES` and ensure
every referenced field exists in the env schema in `apps/sim/lib/core/config/env.ts`. Then add
the matching `text` or `secret` input modes to `OAUTH_CLIENT_SETUP_FIELDS` in
`scripts/setup/capability-config.ts`. The CLI catalog is exhaustively typed and checked against
the runtime field list; do not infer secrecy from the field name.
4. If the canonical OAuth service declares `serviceAccountProviderId`, keep
`SERVICE_ACCOUNT_METADATA_BY_OAUTH_SERVICE_ID` in
`apps/sim/lib/integrations/service-account-metadata.ts` aligned. Set
`deploymentRequirement` only when the service-account path is preview-gated or depends on the
OAuth client fields; otherwise omit it.
Missing capability metadata is a runtime configuration error, not a reason to make the integration
silently available.
### Selectors (with dynamic options)
```typescript
// Channel selector (Slack, Discord, etc.)
@@ -913,12 +939,25 @@ Derive templates from the service's real use cases. Each prompt should name a co
- **Ground every skill in operations the block actually exposes** — cross-check each skill's steps against `tools.access`. Never describe an action the integration cannot perform.
- **Derive skills from real, popular use cases found online — never invent them.** Web-search the service's documented use cases (vendor use-case/solutions pages, official docs describing the workflow, reputable "top automations for X" articles) and only add a skill you can source as something people genuinely do with the service. Do not hallucinate skills.
## Generated tool metadata
## Generated artifacts
Adding a block on its own needs **no** regeneration — a block references existing tool IDs through `tools.access` and does not change any tool's shape.
Adding a block on its own needs no **tool metadata** regeneration — a block references existing
tool IDs through `tools.access` and does not change any tool's shape.
But if the same change also adds, edits **or removes** a tool, run `bun run tool-metadata:generate` and commit the result, or CI fails on stale artifacts. That matters here because a block's `outputs` are authored to match its tools' outputs, and the UI now reads those from the generated metadata rather than the executable registry — an unregenerated tool change makes the block's outputs disagree with what the panel renders. See `.agents/skills/tool-registry-boundary/SKILL.md`.
A visible integration block does require the generated integration catalog and docs to be refreshed.
After adding or changing one, run:
```bash
bun run scripts/generate-docs.ts
bun run integration-catalog:check
```
The catalog check independently derives deployment metadata from the executable block registry and
compares it with the committed `apps/sim/lib/integrations/integrations.json`. Review the generated
diff and keep only intentional changes.
## Checklist Before Finishing
- [ ] `integrationType` is set to the correct `IntegrationType` enum value
@@ -928,12 +967,17 @@ But if the same change also adds, edits **or removes** a tool, run `bun run tool
- [ ] DependsOn set for fields that need other values
- [ ] Required fields marked correctly (boolean or condition)
- [ ] OAuth inputs have correct `serviceId` and `requiredScopes: getScopesForService(serviceId)`
- [ ] Every OAuth `serviceId` resolves through `resolveOAuthClientCapabilityId()` to the correct `OAUTH_CLIENT_CAPABILITIES` entry
- [ ] Any new OAuth capability fields exist in `apps/sim/lib/core/config/env.ts`
- [ ] If the OAuth service supports service accounts, `SERVICE_ACCOUNT_METADATA_BY_OAUTH_SERVICE_ID` matches its canonical `serviceAccountProviderId` and deployment requirement
- [ ] Scope descriptions added to `SCOPE_DESCRIPTIONS` in `lib/oauth/utils.ts` for any new scopes
- [ ] Tools.access lists all tool IDs (snake_case)
- [ ] Tools.config.tool returns correct tool ID (snake_case)
- [ ] Outputs match tool outputs
- [ ] Block + meta registered in registry-maps.ts (`BLOCK_REGISTRY` / `BLOCK_META_REGISTRY`)
- [ ] If any tool was added, changed or removed alongside the block: ran `bun run tool-metadata:generate` and committed the artifacts
- [ ] Ran `bun run scripts/generate-docs.ts`, reviewed the generated diff, and committed the integration catalog changes
- [ ] `bun run integration-catalog:check` passes
- [ ] If icon missing: asked user to provide SVG
- [ ] If triggers exist: `triggers` config set, trigger subBlocks spread
- [ ] Optional/rarely-used fields set to `mode: 'advanced'`
+47 -2
View File
@@ -11,7 +11,8 @@ Adding an integration involves these steps in order:
4. **Add Icon** - Add the service's brand icon
5. **Create Triggers** (optional) - If the service supports webhooks
6. **Register** - Register tools, block, and triggers in their registries
7. **Generate Docs** - Run the docs generation script
7. **Configure Deployment Availability** - Wire OAuth client and service-account metadata
8. **Generate and Validate the Catalog** - Regenerate docs/catalog artifacts and run drift checks
## Step 1: Research the API
@@ -459,15 +460,48 @@ export const TRIGGER_REGISTRY: TriggerRegistry = {
}
```
## Step 7: Generate Docs
## Step 7: Configure Deployment Availability
Do this for every visible OAuth integration. API-key and unauthenticated integrations do not need
an OAuth client capability.
The block's `oauth-input.serviceId` is the canonical link between the generated integration catalog,
the OAuth service configuration, deployment availability, and the setup CLI.
1. Ensure the block has exactly one distinct OAuth `serviceId` and that it matches the canonical
service entry in `apps/sim/lib/oauth/oauth.ts`.
2. Confirm `resolveOAuthClientCapabilityId(serviceId)` resolves to the intended provider entry in
`OAUTH_CLIENT_CAPABILITIES` in `apps/sim/lib/core/config/env-capabilities.ts`. Google and
Microsoft service IDs deliberately share provider-level capabilities.
3. For a new OAuth provider, add the required client fields to `OAUTH_CLIENT_CAPABILITIES`, add
every referenced field to the env schema in `apps/sim/lib/core/config/env.ts`, and add the
matching `text` or `secret` entries to `OAUTH_CLIENT_SETUP_FIELDS` in
`scripts/setup/capability-config.ts`. Do not create integration-specific setup logic or infer
secret fields from naming; the CLI mapping is exhaustively checked against the runtime fields.
4. If the canonical OAuth service has `serviceAccountProviderId`, add the matching projection to
`SERVICE_ACCOUNT_METADATA_BY_OAUTH_SERVICE_ID` in
`apps/sim/lib/integrations/service-account-metadata.ts`. Use:
- no `deploymentRequirement` when the service-account path works independently of OAuth client fields;
- `'oauth-client'` when it requires the same deployment OAuth client fields;
- `'preview-gated'` when availability is controlled by the service-account preview block.
Never add a permissive fallback for missing capability metadata. A visible OAuth integration without
a resolvable capability must fail validation.
## Step 8: Generate and Validate the Catalog
Run the documentation generator:
```bash
bun run scripts/generate-docs.ts
bun run integration-catalog:check
```
This creates `apps/docs/content/docs/en/integrations/{service}.mdx` — one page per service carrying the block's Actions and, if it has one, its Triggers section. Never hand-edit generated pages; the only editable region is the `{/* MANUAL-CONTENT */}` block (see `scripts/README.md`).
The same generator refreshes `apps/sim/lib/integrations/integrations.json`. The catalog check then
derives the deployment-relevant fields from the executable block registry and compares them with the
committed projection. Review the generated diff and keep only intentional changes.
## V2 Integration Pattern
If creating V2 versions (API-aligned outputs):
@@ -518,6 +552,13 @@ If creating V2 versions (API-aligned outputs):
- [ ] Used `getCanonicalScopesForProvider()` in `auth.ts` (never hardcode)
- [ ] Used `getScopesForService()` in block `requiredScopes` (never hardcode)
### Deployment Availability (if OAuth service)
- [ ] Block declares exactly one distinct `oauth-input.serviceId`
- [ ] `resolveOAuthClientCapabilityId(serviceId)` resolves to the intended `OAUTH_CLIENT_CAPABILITIES` entry
- [ ] Every new OAuth capability field exists in `apps/sim/lib/core/config/env.ts`
- [ ] Runtime OAuth fields live in `OAUTH_CLIENT_CAPABILITIES`; matching CLI input modes live in the exhaustively checked `OAUTH_CLIENT_SETUP_FIELDS`
- [ ] If `serviceAccountProviderId` is configured, `SERVICE_ACCOUNT_METADATA_BY_OAUTH_SERVICE_ID` has the matching projection and deployment requirement
### Icon
- [ ] Asked user to provide SVG
- [ ] Added icon to `components/icons.tsx`
@@ -536,6 +577,8 @@ If creating V2 versions (API-aligned outputs):
### Docs
- [ ] Ran `bun run scripts/generate-docs.ts`
- [ ] Verified docs file created
- [ ] Reviewed and committed the generated `apps/sim/lib/integrations/integrations.json` change
- [ ] `bun run integration-catalog:check` passes
### Final Validation (Required)
- [ ] Read every tool file and cross-referenced inputs/outputs against the API docs
@@ -880,3 +923,5 @@ requiredScopes: getScopesForService('{service}'),
10. **Complex inputs need wandConfig** - Timestamps, JSON arrays, and other hard-to-type values should have `wandConfig` enabled
11. **Never hardcode scopes** - Use `getScopesForService()` in blocks and `getCanonicalScopesForProvider()` in auth.ts
12. **Always add scope descriptions** - New scopes must have entries in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts`
13. **OAuth service IDs need deployment capabilities** - Every visible OAuth integration must resolve through `OAUTH_CLIENT_CAPABILITIES`; shared Google/Microsoft aliases map to their provider capability
14. **Keep runtime and presentation separate** - Runtime OAuth fields live in `env-capabilities.ts`; CLI input modes live in the exhaustively checked `scripts/setup/capability-config.ts` mapping
+10 -4
View File
@@ -48,17 +48,23 @@ When the user runs `/ship`:
```
Then `git status --short` to see what regenerated — those files must be staged in step 7 alongside your own changes.
**Do NOT blanket-run the domain generators here.** `mship:generate` (`generate-mship-contracts.ts`) is an **umbrella** that drives all nine mothership contract generators (`mship-contracts`, `billing-protocol-contract`, `mship-tools`, the four `trace-*`, `metrics-contract`, `vfs-snapshot-contract`) and biome-formats `apps/sim/lib/copilot/generated/` — never run it *and* its constituents (they write the same files and corrupt each other in parallel), and never run it on an ordinary ship: it reads an **external** copilot-contract source that isn't checked out in most worktrees, so it hard-fails with `ENOENT` and would abort ship for an unrelated reason. `generate:pi-model-catalog` (under `apps/sim`) likewise regenerates from the installed Pi package, not repo source. Only when **this PR's diff actually touches** a domain generator's input do you regenerate it deliberately and run its matching `:check` (`bun run mship:check` / the individual `*:check`) — with the external source present.
**Do NOT blanket-run the domain generators here.** `mship:generate` (`generate-mship-contracts.ts`) is an **umbrella** that drives all nine mothership contract generators (`mship-contracts`, `billing-protocol-contract`, `mship-tools`, the four `trace-*`, `metrics-contract`, `vfs-snapshot-contract`) and biome-formats `apps/sim/lib/copilot/generated/` — never run it *and* its constituents (they write the same files and corrupt each other in parallel), and never run it on an ordinary ship: it reads an **external** copilot-contract source that isn't checked out in most worktrees, so it hard-fails with `ENOENT` and would abort ship for an unrelated reason. `generate:pi-model-catalog` (under `apps/sim`) likewise regenerates from the installed Pi package, not repo source. `scripts/generate-docs.ts` rewrites the integration docs and client-safe catalog; run it when this PR changes their block/icon/landing-content inputs or when `integration-catalog:check` reports drift, then review its broad generated diff. Only when **this PR's diff actually touches** a domain generator's input do you regenerate it deliberately and run its matching `:check` (`bun run mship:check` / the individual `*:check`) — with the external source present.
**Phase B — run lint + every audit CI enforces, in parallel, and abort ship if any fails.** `bun run lint` first (it autofixes formatting and mutates files, so don't parallelize it with the read-only audits), then fan the rest out and collect exit codes. This is exactly the read-only audit set from CI's `Lint and Test` job (all in-repo, runnable in any worktree):
**Phase B — run lint + every audit CI enforces, in parallel, and abort ship if any fails.** Before running the commands, compare this list with `.github/workflows/test-build.yml`; when CI adds an audit, run it and update this skill instead of trusting a stale snapshot. The env-flag audit is currently an inline workflow block rather than a package script: when `apps/sim/lib/core/config/env-flags.ts` changed, run that current workflow block verbatim instead of copying a second version into this skill. Run `bun run lint` first (it autofixes formatting and mutates files, so don't parallelize it with the read-only audits), then run the base-sensitive block-registry check, then fan the independent audits out and collect exit codes:
```bash
# autofix formatting first (mutating; not parallel-safe with the audits). Gate its exit too —
# a non-zero lint (unfixable errors) must abort before the audits run, not be ignored.
bun run lint || { echo "❌ lint failed — do not ship"; exit 1; }
bun run apps/sim/scripts/check-block-registry.ts origin/staging || {
echo "❌ block registry audit failed — do not ship"
exit 1
}
rm -f /tmp/ship-audit-results
for s in check:boundaries check:api-validation:strict check:utils check:zustand-v5 \
for s in check:boundaries check:api-validation:strict check:desktop-bridge check:desktop-ipc \
check:utils check:zustand-v5 \
check:react-query check:client-boundary check:bare-icons check:icon-paths \
check:realtime-prune skills:check agent-stream-docs:check; do
check:realtime-prune check:tool-registry-boundary tool-metadata:check \
integration-catalog:check skills:check agent-stream-docs:check; do
( bun run "$s" >"/tmp/ship-audit-${s//:/-}.log" 2>&1; echo "$? $s" >>/tmp/ship-audit-results ) &
done
wait
+45 -7
View File
@@ -24,6 +24,11 @@ apps/sim/components/icons.tsx # Icon definition
apps/sim/lib/auth/auth.ts # OAuth config — should use getCanonicalScopesForProvider()
apps/sim/lib/oauth/oauth.ts # OAuth provider config — single source of truth for scopes
apps/sim/lib/oauth/utils.ts # Scope utilities, SCOPE_DESCRIPTIONS for modal UI
apps/sim/lib/core/config/env-capabilities.ts # OAuth client runtime capability source of truth
apps/sim/lib/core/config/env.ts # Runtime env schema for capability fields
scripts/setup/capability-config.ts # Exhaustive CLI input-mode mapping for OAuth fields
apps/sim/lib/integrations/integrations.json # Generated client-safe integration catalog
apps/sim/lib/integrations/service-account-metadata.ts # Lightweight service-account projection
```
## Step 2: Pull API Documentation
@@ -227,7 +232,28 @@ Scopes are centralized — the single source of truth is `OAUTH_PROVIDERS` in `l
- [ ] Each scope has a human-readable description in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts`
- [ ] No excess scopes that aren't needed by any tool
## Step 6: Validate Pagination Consistency
## Step 6: Validate Deployment Availability (if OAuth service)
The deployment UI and setup CLI do not infer OAuth client fields from scopes. They resolve the
block's generated `oauthServiceId` through the application-owned capability catalog.
- [ ] The visible integration block has exactly one distinct `oauth-input.serviceId`
- [ ] `resolveOAuthClientCapabilityId(serviceId)` returns the intended provider capability
- [ ] The resolved provider exists in `OAUTH_CLIENT_CAPABILITIES`
- [ ] Every field listed by that capability exists in `apps/sim/lib/core/config/env.ts`
- [ ] Every capability field has the correct `text` or `secret` entry in `OAUTH_CLIENT_SETUP_FIELDS`; no CLI naming heuristic is required
- [ ] Shared Google/Microsoft service IDs resolve to their provider capability rather than duplicate entries
- [ ] `bun run setup integration <capabilityId>` is the command emitted by availability; the CLI has only the exhaustive input-mode projection, not a second runtime provider definition
- [ ] If the canonical OAuth service declares `serviceAccountProviderId`,
`SERVICE_ACCOUNT_METADATA_BY_OAUTH_SERVICE_ID[serviceId]` has the same provider ID
- [ ] The service-account `deploymentRequirement` matches how that credential actually works:
omitted for an independent path, `'oauth-client'` when it needs the OAuth client fields, or
`'preview-gated'` when controlled by a preview block
Treat a missing capability as **critical**: runtime availability intentionally throws instead of
silently exposing an unusable integration.
## Step 7: Validate Pagination Consistency
If any tools support pagination:
- [ ] Pagination param names match the API docs (e.g., `pagination_token` vs `next_token` vs `cursor`)
@@ -235,7 +261,7 @@ If any tools support pagination:
- [ ] Pagination response fields (`nextToken`, `cursor`, etc.) are included in tool outputs
- [ ] Pagination subBlocks are set to `mode: 'advanced'`
## Step 7: Validate Memory Load Safety
## Step 8: Validate Memory Load Safety
If any tool lists, searches, exports, imports, downloads, uploads, paginates, batches, transforms arrays, or reads file/HTTP bodies, read `.agents/skills/memory-load-check/SKILL.md` and apply it to the integration.
@@ -245,13 +271,13 @@ If any tool lists, searches, exports, imports, downloads, uploads, paginates, ba
- [ ] Large result payloads are summarized, paginated, referenced, or capped rather than raw-dumped
- [ ] Pagination and download tests cover caps, early stop behavior, or partial-result preservation when relevant
## Step 8: Validate Error Handling
## Step 9: Validate Error Handling
- [ ] `transformResponse` checks for error conditions before accessing data
- [ ] Error responses include meaningful messages (not just generic "failed")
- [ ] HTTP error status codes are handled (check `response.ok` or status codes)
## Step 9: Report and Fix
## Step 10: Report and Fix
### Report Format
@@ -264,6 +290,9 @@ Group findings by severity:
- Missing error handling that would cause crashes
- Tool ID mismatch between tool file, registry, and block `tools.access`
- OAuth scopes missing in `auth.ts` that tools need
- OAuth integration `serviceId` missing from the deployment capability catalog
- Capability references an env field absent from the runtime env schema
- Service-account metadata disagrees with the canonical OAuth service configuration
- `tools.config.tool` returning wrong tool ID for an operation
- Type coercions in `tools.config.tool` instead of `tools.config.params`
@@ -295,11 +324,15 @@ Several files are generated from tool and block definitions. Editing a tool or b
```bash
bun run tool-metadata:generate # repo root — apps/sim/tools/generated/*
cd apps/sim && bun run generate-docs # docs .mdx + lib/integrations/integrations.json + docs icons
bun run scripts/generate-docs.ts # docs .mdx + lib/integrations/integrations.json + docs icons
bun run integration-catalog:check # registry ↔ committed deployment metadata drift
```
- **`tool-metadata:generate`** — required whenever a tool's `outputs`, `params`, or descriptions change. CI enforces this with `bun run tool-metadata:check`, which fails with *"Generated tool metadata is stale"*. This is the easiest gate to miss, because nothing in the tool file hints that a generated artifact mirrors it.
- **`generate-docs`** — required whenever block metadata changes (`bgColor`, `name`, `description`, operations, outputs). Regenerates the integration `.mdx`, `integrations.json`, and the docs copy of `components/icons.tsx`.
- **`integration-catalog:check`** — loads the executable block registry, derives visible integration
deployment fields, and compares them with the committed catalog. It catches missing/unexpected
entries and stale auth/service IDs without loading the executable registry in client code.
**Always diff the regen output before committing.** These generators rewrite every file they own, so they will also sweep in unrelated drift that accumulated on the base branch — pages losing sections, unrelated icons appearing. Keep only the hunks belonging to the integration under validation and `git checkout --` the rest, otherwise an unrelated doc regression rides along in the PR. Verify no page was silently dropped by comparing the directory listing before and after.
@@ -312,8 +345,10 @@ After fixing, confirm:
2. TypeScript compiles clean (no type errors) — check the error list is empty for the files you touched; pre-existing unrelated errors in a worktree usually mean workspace packages resolve to the main checkout
3. The integration's tests pass, and any test you added actually fails without its fix (revert it once and watch it go red)
4. Derived artifacts regenerated and their diffs reviewed (see above)
5. Re-read all modified files to verify fixes are correct
6. Any remaining unknown response schemas were explicitly reported to the user instead of guessed
5. `bun run integration-catalog:check` passes
6. For OAuth or service-account changes, `bun test apps/sim/lib/integrations/availability.server.test.ts` passes
7. Re-read all modified files to verify fixes are correct
8. Any remaining unknown response schemas were explicitly reported to the user instead of guessed
## Checklist Summary
@@ -327,6 +362,9 @@ After fixing, confirm:
- [ ] Validated block outputs match what tools return, with typed JSON where possible
- [ ] Validated OAuth scopes use centralized utilities (getScopesForService, getCanonicalScopesForProvider) — no hardcoded arrays
- [ ] Validated scope descriptions exist in `SCOPE_DESCRIPTIONS` within `lib/oauth/utils.ts` for all scopes
- [ ] Validated OAuth `serviceId` resolves to the intended `OAUTH_CLIENT_CAPABILITIES` entry and all capability fields exist in the env schema
- [ ] Validated service-account projection and deployment requirement against the canonical OAuth service config
- [ ] Regenerated `integrations.json` when block metadata changed and ran `bun run integration-catalog:check`
- [ ] Validated pagination consistency across tools and block
- [ ] Validated memory load safety using `.agents/skills/memory-load-check/SKILL.md` when tools list/search/download/import/export/batch data
- [ ] Validated error handling (error checks, meaningful messages)
+1 -1
View File
@@ -316,7 +316,7 @@ If you prefer not to use Docker or Dev Containers. **All commands run from the r
```bash
bun run type-check # TypeScript across every workspace
bun run lint:check # Biome lint across every workspace
bun run test # Vitest across every workspace
bun run test # Setup CLI Bun tests, then Vitest across every workspace
```
### Email Template Development
+5 -2
View File
@@ -159,6 +159,9 @@ jobs:
- name: Verify generated tool metadata is in sync
run: bun run tool-metadata:check
- name: Verify integration deployment metadata is in sync
run: bun run integration-catalog:check
- name: Verify skill projections are in sync
run: bun run skills:check
@@ -183,8 +186,8 @@ jobs:
- name: Install ripgrep
run: command -v rg || (sudo apt-get update && sudo apt-get install -y ripgrep)
# Named for what it does: `bun run test` is `vitest run`, with no
# `--coverage`. See the Codecov note below.
# Runs the setup CLI's Bun tests plus each workspace's Vitest suite,
# without `--coverage`. See the Codecov note below.
- name: Run tests
env:
NODE_OPTIONS: '--no-warnings --max-old-space-size=8192'
+20
View File
@@ -28,6 +28,7 @@
```bash
git clone https://github.com/simstudioai/sim.git && cd sim
bun install
bun run setup
```
@@ -81,6 +82,25 @@ Open [http://localhost:3000](http://localhost:3000)
When it finishes, open [http://localhost:3000](http://localhost:3000).
Reconfigure an optional capability without rerunning the full wizard:
```bash
bun run setup status
bun run setup email
bun run setup storage
bun run setup sandbox
bun run setup jobs
bun run setup cache
bun run setup knowledge
bun run setup llm
bun run setup integration slack
```
`bun run setup status` detects the effective local-dev, Docker Compose, or current-context
Helm configuration and reports configured, missing, or invalid capabilities and OAuth
integrations without printing credential values. This is separate from `bun run sim status`,
which reports whether installed services are running and healthy.
Manage your install with `bun run sim`:
```bash
@@ -23,14 +23,14 @@ Sim stores every uploaded file — knowledge base documents, chat attachments, e
## How the backend is selected
Sim picks the backend automatically from environment variables — there is no explicit "provider" flag. The logic, in order of precedence:
Set `STORAGE_PROVIDER` to `local`, `s3`, `azure`, or `gcs` to select a backend explicitly. When it is unset, Sim infers the backend from the configured environment variables in this order:
1. **Azure Blob** — used if `AZURE_STORAGE_CONTAINER_NAME` is set **and** either (`AZURE_ACCOUNT_NAME` + `AZURE_ACCOUNT_KEY`) or `AZURE_CONNECTION_STRING` is set.
2. **AWS S3** — used if `S3_BUCKET_NAME` **and** `AWS_REGION` are set (and Azure is not configured).
3. **Google Cloud Storage** — used if `GCS_BUCKET_NAME` is set (and neither Azure nor S3 is configured).
4. **Local disk** — the fallback when none is configured.
If more than one backend is configured, the first match in that order wins. Set only the variables for the backend you intend to use.
If `STORAGE_PROVIDER` is unset, Sim skips incomplete backends and uses the first ready match in that order. A higher-priority backend with its required fields present but invalid supplied values fails fast instead of silently falling through. An explicit `STORAGE_PROVIDER` takes precedence and must be valid and complete.
## Set up AWS S3
+9 -5
View File
@@ -32,9 +32,9 @@ API_ENCRYPTION_KEY=your_api_encryption_key # Use `openssl rand -hex 32` to gener
CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authenticates the scheduler against the background job endpoints (scheduled workflows, polling triggers, connector syncs)
# Email Provider (Optional)
# Configure ONE provider — the mailer auto-detects in priority order:
# Resend → AWS SES → SMTP → Azure Communication Services → Gmail. If none
# are configured, emails are logged to console instead.
# Configure one or more providers. Every configured provider stays active and is
# tried in order: Resend → AWS SES → SMTP → Azure Communication Services → Gmail.
# If none are configured, emails are logged to console instead.
#
# Resend
# RESEND_API_KEY= # API key from https://resend.com
@@ -96,7 +96,11 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic
# CONTEXT_DEV_API_KEY_1= # Context.dev API key #1
# CONTEXT_DEV_API_KEY_2= # Context.dev API key #2
# PDF OCR provider (Optional - defaults to local; legacy installs infer Mistral from configured credentials)
# OCR_PROVIDER=local # One of: local, mistral, azure-mistral
# File Storage (Optional - defaults to local disk; use S3, Azure Blob, or Google Cloud Storage for production)
# STORAGE_PROVIDER=local # Optional override: local, s3, azure, or gcs. Unset preserves Azure → S3 → GCS → local precedence
# AWS_REGION=us-east-1 # Required with S3_BUCKET_NAME to enable S3. Use "auto" for Cloudflare R2
# AWS_ACCESS_KEY_ID= # Omit to use the instance/IRSA credential chain
# AWS_SECRET_ACCESS_KEY= # Omit to use the instance/IRSA credential chain
@@ -121,7 +125,7 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic
# TIKTOK_CLIENT_ID=
# TIKTOK_CLIENT_SECRET=
# Azure Blob Storage takes precedence over S3 if both are configured
# Azure Blob Storage
# AZURE_ACCOUNT_NAME= # Azure storage account name
# AZURE_ACCOUNT_KEY= # Azure storage account key
# AZURE_CONNECTION_STRING= # Alternative to account name/key
@@ -134,7 +138,7 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic
# AZURE_STORAGE_OG_IMAGES_CONTAINER_NAME= # OpenGraph preview images (falls back to AZURE_STORAGE_CONTAINER_NAME)
# AZURE_STORAGE_WORKSPACE_LOGOS_CONTAINER_NAME= # Workspace logos (falls back to AZURE_STORAGE_CONTAINER_NAME)
# Google Cloud Storage (used when neither Azure Blob nor S3 is configured)
# Google Cloud Storage
# GCS_PROJECT_ID= # GCP project ID (optional — inferred from credentials/ADC when unset)
# GCS_CREDENTIALS_JSON= # Inline service-account JSON. Omit to use Application Default Credentials (Workload Identity, GOOGLE_APPLICATION_CREDENTIALS)
# GCS_BUCKET_NAME= # General workspace files bucket (enables GCS; all other buckets fall back to it)
@@ -4,7 +4,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { authorizeInstagramContract } from '@/lib/api/contracts/oauth-connections'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { env } from '@/lib/core/config/env'
import { requireConfiguredOAuthClient } from '@/lib/core/config/env-capabilities.server'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { isSameOrigin } from '@/lib/core/utils/validation'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
@@ -28,11 +28,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const clientId = env.INSTAGRAM_CLIENT_ID
if (!clientId) {
logger.error('INSTAGRAM_CLIENT_ID not configured')
return NextResponse.json({ error: 'Instagram client ID not configured' }, { status: 500 })
}
const {
values: { INSTAGRAM_CLIENT_ID: clientId },
} = requireConfiguredOAuthClient('instagram')
const parsed = await parseRequest(authorizeInstagramContract, request, {})
if (!parsed.success) return parsed.response
@@ -81,7 +81,11 @@ describe('OAuth2 authorize route', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
setEnv({ NEXT_PUBLIC_APP_URL: BASE_URL })
setEnv({
NEXT_PUBLIC_APP_URL: BASE_URL,
GOOGLE_CLIENT_ID: 'google-client',
GOOGLE_CLIENT_SECRET: 'google-secret',
})
mockGetSession.mockResolvedValue({ user: { id: USER_ID } })
mockCheckWorkspaceAccess.mockResolvedValue({
hasAccess: true,
@@ -139,6 +143,18 @@ describe('OAuth2 authorize route', () => {
expect(set).toHaveProperty('credentialId', null)
})
it('rejects an OAuth client that is not configured for the deployment', async () => {
setEnv({ GOOGLE_CLIENT_ID: undefined, GOOGLE_CLIENT_SECRET: undefined })
const response = await GET(
authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID })
)
expect(response.headers.get('location')).toBe(`${BASE_URL}/workspace?error=oauth_link_failed`)
expect(dbChainMockFns.values).not.toHaveBeenCalled()
expect(mockOAuth2LinkAccount).not.toHaveBeenCalled()
})
it('redirects to login when unauthenticated', async () => {
mockGetSession.mockResolvedValue(null)
@@ -3,6 +3,7 @@ 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 { requireConfiguredOAuthClient } from '@/lib/core/config/env-capabilities.server'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getCredentialActorContext } from '@/lib/credentials/access'
@@ -100,6 +101,8 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
reconnectDisplayName = actor.credential.displayName
}
requireConfiguredOAuthClient(providerId)
// Create the draft before initiating the link so it is guaranteed to exist
// (and freshly clocked) when the OAuth callback's `account.create.after`
// hook runs. If this throws, we never start the OAuth flow.
@@ -7,7 +7,8 @@ import { type NextRequest, NextResponse } from 'next/server'
import { instagramCallbackContract } from '@/lib/api/contracts/oauth-connections'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { env } from '@/lib/core/config/env'
import { EnvCapabilityConfigurationError } from '@/lib/core/config/env-capabilities'
import { requireConfiguredOAuthClient } from '@/lib/core/config/env-capabilities.server'
import {
DEFAULT_MAX_ERROR_BODY_BYTES,
readResponseJsonWithLimit,
@@ -76,14 +77,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
)
}
const clientId = env.INSTAGRAM_CLIENT_ID
const clientSecret = env.INSTAGRAM_CLIENT_SECRET
if (!clientId || !clientSecret) {
logger.error('Instagram credentials not configured')
return clearOAuthCookies(
NextResponse.redirect(`${baseUrl}/workspace?error=instagram_config_error`)
)
}
const {
values: { INSTAGRAM_CLIENT_ID: clientId, INSTAGRAM_CLIENT_SECRET: clientSecret },
} = requireConfiguredOAuthClient('instagram')
if (!code) {
logger.error('No authorization code received from Instagram')
@@ -318,8 +314,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
return clearOAuthCookies(NextResponse.redirect(finalUrl.toString()))
} catch (error) {
logger.error('Error in Instagram OAuth callback', { error })
return clearOAuthCookies(
NextResponse.redirect(`${baseUrl}/workspace?error=instagram_callback_error`)
)
const errorCode =
error instanceof EnvCapabilityConfigurationError && error.capabilityId === 'oauth'
? 'instagram_config_error'
: 'instagram_callback_error'
return clearOAuthCookies(NextResponse.redirect(`${baseUrl}/workspace?error=${errorCode}`))
}
})
@@ -7,7 +7,8 @@ import {
shopifyShopDomainSchema,
} from '@/lib/api/contracts/oauth-connections'
import { getSession } from '@/lib/auth'
import { env } from '@/lib/core/config/env'
import { EnvCapabilityConfigurationError } from '@/lib/core/config/env-capabilities'
import { requireConfiguredOAuthClient } from '@/lib/core/config/env-capabilities.server'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
@@ -61,13 +62,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
const storedState = request.cookies.get('shopify_oauth_state')?.value
const storedShop = request.cookies.get('shopify_shop_domain')?.value
const clientId = env.SHOPIFY_CLIENT_ID
const clientSecret = env.SHOPIFY_CLIENT_SECRET
if (!clientId || !clientSecret) {
logger.error('Shopify credentials not configured')
return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_config_error`)
}
const {
values: { SHOPIFY_CLIENT_ID: clientId, SHOPIFY_CLIENT_SECRET: clientSecret },
} = requireConfiguredOAuthClient('shopify')
if (!validateHmac(searchParams, clientSecret)) {
logger.error('HMAC validation failed in Shopify OAuth callback')
@@ -164,6 +161,10 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
return response
} catch (error) {
logger.error('Error in Shopify OAuth callback:', error)
return NextResponse.redirect(`${baseUrl}/workspace?error=shopify_callback_error`)
const errorCode =
error instanceof EnvCapabilityConfigurationError && error.capabilityId === 'oauth'
? 'shopify_config_error'
: 'shopify_callback_error'
return NextResponse.redirect(`${baseUrl}/workspace?error=${errorCode}`)
}
})
@@ -6,7 +6,7 @@ import {
shopifyShopDomainSchema,
} from '@/lib/api/contracts/oauth-connections'
import { getSession } from '@/lib/auth'
import { env } from '@/lib/core/config/env'
import { requireConfiguredOAuthClient } from '@/lib/core/config/env-capabilities.server'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { isSameOrigin } from '@/lib/core/utils/validation'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
@@ -25,12 +25,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const clientId = env.SHOPIFY_CLIENT_ID
if (!clientId) {
logger.error('SHOPIFY_CLIENT_ID not configured')
return NextResponse.json({ error: 'Shopify client ID not configured' }, { status: 500 })
}
const {
values: { SHOPIFY_CLIENT_ID: clientId },
} = requireConfiguredOAuthClient('shopify')
const query = shopifyAuthorizeQuerySchema.parse({
shop: request.nextUrl.searchParams.get('shop') || undefined,
@@ -2,6 +2,7 @@ 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'
export const GET = withRouteHandler(async () => {
const session = await getSession()
@@ -11,5 +12,8 @@ export const GET = withRouteHandler(async () => {
return NextResponse.json({
allowedIntegrations: getAllowedIntegrationsFromEnv(),
integrationAvailability: getIntegrationAvailability().map(
({ type, state, oauthAvailable }) => ({ type, state, oauthAvailable })
),
})
})
@@ -24,7 +24,10 @@ import {
} from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal'
import { IntegrationSection } from '@/app/workspace/[workspaceId]/integrations/components/integration-section'
import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
import { CONNECT_MODE } from '@/app/workspace/[workspaceId]/integrations/connect-route'
import {
CONNECT_MODE,
resolveAvailableConnectMode,
} from '@/app/workspace/[workspaceId]/integrations/connect-route'
import { useScrollRestoration } from '@/app/workspace/[workspaceId]/integrations/hooks/use-scroll-restoration'
import {
RESOURCE_LIST_STACK,
@@ -40,6 +43,7 @@ import {
} from '@/blocks/registry'
import { useWorkspaceCredentials } from '@/hooks/queries/credentials'
import { useOAuthReturnRouter } from '@/hooks/use-oauth-return'
import { usePermissionConfig } from '@/hooks/use-permission-config'
/** Maximum number of overlapping icon tiles rendered per template row. */
const TEMPLATE_CLUSTER_MAX = 3 as const
@@ -64,6 +68,9 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
const matchingTemplates = getTemplatesForBlock(integration.type)
const suggestedSkills = getSuggestedSkillsForBlock(integration.type)
const oauthService = resolveOAuthServiceForIntegration(integration)
const { integrationAvailability, isLoading: permissionConfigLoading } = usePermissionConfig()
const availability = integrationAvailability.get(integration.type.toLowerCase())
const oauthAvailable = Boolean(oauthService) && (availability?.oauthAvailable ?? true)
const [oauthOpen, setOAuthOpen] = useState(false)
const { data: credentials = [], isPending: credentialsLoading } = useWorkspaceCredentials({
@@ -93,40 +100,61 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
serviceName: oauthService?.serviceName,
serviceIcon: oauthService?.serviceIcon,
})
const hasServiceAccount = Boolean(serviceAccountTarget) && !serviceAccountTarget?.hidden
const serviceAccountDeploymentAvailable =
availability?.state === 'ready' || availability?.state === 'limited'
const hasServiceAccount =
serviceAccountDeploymentAvailable &&
Boolean(serviceAccountTarget) &&
!serviceAccountTarget?.hidden
const serviceAccountConnectLabel = serviceAccountTarget?.label ?? 'Add service account'
const hasHandledConnectQueryRef = useRef(false)
useEffect(() => {
if (hasHandledConnectQueryRef.current) return
if (!connectMode) return
if (hasHandledConnectQueryRef.current || !connectMode || permissionConfigLoading) return
let handled = false
if (connectMode === CONNECT_MODE.oauth && oauthService) {
const availableConnectMode = resolveAvailableConnectMode(connectMode, {
oauth: Boolean(oauthService) && oauthAvailable,
serviceAccount: hasServiceAccount,
})
if (!availableConnectMode) return
if (availableConnectMode === CONNECT_MODE.oauth) {
setOAuthOpen(true)
handled = true
} else if (connectMode === CONNECT_MODE.serviceAccount && hasServiceAccount) {
} else {
setServiceAccountOpen(true)
handled = true
}
if (!handled) return
hasHandledConnectQueryRef.current = true
void setConnectMode(null, { history: 'replace', scroll: false })
}, [connectMode, oauthService, hasServiceAccount, setConnectMode])
}, [
connectMode,
oauthService,
oauthAvailable,
hasServiceAccount,
permissionConfigLoading,
setConnectMode,
])
const connectOptions = oauthService
? [
{
value: CONNECT_MODE.oauth,
label: 'Connect with OAuth',
icon: oauthService.serviceIcon,
},
{
value: CONNECT_MODE.serviceAccount,
label: serviceAccountConnectLabel,
icon: serviceAccountTarget?.serviceIcon ?? oauthService.serviceIcon,
},
...(oauthAvailable
? [
{
value: CONNECT_MODE.oauth,
label: 'Connect with OAuth',
icon: oauthService.serviceIcon,
},
]
: []),
...(hasServiceAccount
? [
{
value: CONNECT_MODE.serviceAccount,
label: serviceAccountConnectLabel,
icon: serviceAccountTarget?.serviceIcon ?? oauthService.serviceIcon,
},
]
: []),
]
: []
@@ -148,7 +176,7 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
</ChipLink>
<div className={cn('ml-auto', HEADER_ACTION_CLUSTER)}>
{oauthService ? (
hasServiceAccount ? (
connectOptions.length > 1 ? (
<ChipDropdown
variant='primary'
leftIcon={Plus}
@@ -158,10 +186,16 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
onChange={handleSelectConnectOption}
matchTriggerWidth={false}
/>
) : (
) : oauthAvailable ? (
<Chip variant='primary' leftIcon={Plus} onClick={() => setOAuthOpen(true)}>
Add to Sim
</Chip>
) : hasServiceAccount ? (
<Chip variant='primary' leftIcon={Plus} onClick={() => setServiceAccountOpen(true)}>
{serviceAccountConnectLabel}
</Chip>
) : (
<Chip disabled>Unavailable</Chip>
)
) : isChatEnabled ? (
<Chip variant='primary' leftIcon={Plus} onClick={handleAddInChat}>
@@ -170,7 +204,7 @@ export function IntegrationBlockDetail({ integration, workspaceId }: Integration
) : null}
</div>
</div>
{oauthService && (
{oauthService && oauthAvailable && (
<ConnectOAuthModal
mode='connect'
origin='integrations'
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import {
CONNECT_MODE,
resolveAvailableConnectMode,
} from '@/app/workspace/[workspaceId]/integrations/connect-route'
describe('resolveAvailableConnectMode', () => {
it('keeps a service-account deep link pending until its modal is available', () => {
expect(
resolveAvailableConnectMode(CONNECT_MODE.serviceAccount, {
oauth: false,
serviceAccount: false,
})
).toBeNull()
expect(
resolveAvailableConnectMode(CONNECT_MODE.serviceAccount, {
oauth: false,
serviceAccount: true,
})
).toBe(CONNECT_MODE.serviceAccount)
})
it('only resolves OAuth when OAuth is available', () => {
expect(
resolveAvailableConnectMode(CONNECT_MODE.oauth, {
oauth: false,
serviceAccount: true,
})
).toBeNull()
expect(
resolveAvailableConnectMode(CONNECT_MODE.oauth, {
oauth: true,
serviceAccount: false,
})
).toBe(CONNECT_MODE.oauth)
})
})
@@ -10,3 +10,22 @@ export const CONNECT_MODE = {
oauth: 'oauth',
serviceAccount: 'service-account',
} as const
export type ConnectMode = (typeof CONNECT_MODE)[keyof typeof CONNECT_MODE]
interface ConnectModeAvailability {
oauth: boolean
serviceAccount: boolean
}
/** `null` lets callers preserve the deep-link while deployment and block visibility hydrate. */
export function resolveAvailableConnectMode(
connectMode: ConnectMode,
availability: ConnectModeAvailability
): ConnectMode | null {
if (connectMode === CONNECT_MODE.oauth && availability.oauth) return connectMode
if (connectMode === CONNECT_MODE.serviceAccount && availability.serviceAccount) {
return connectMode
}
return null
}
@@ -36,6 +36,7 @@ import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/compo
import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row'
import { useWorkspaceCredentials, type WorkspaceCredential } from '@/hooks/queries/credentials'
import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter'
import { usePermissionConfig } from '@/hooks/use-permission-config'
/** Slugs surfaced in the pinned Featured section, in display order. */
const FEATURED_SLUGS = ['slack', 'gmail', 'jira', 'github', 'google-sheets', 'hubspot'] as const
@@ -68,6 +69,7 @@ interface IntegrationItemProps {
name: string
description?: string | null
icon: ComponentType<{ className?: string }>
unavailable?: boolean
}
function IntegrationItem({
@@ -77,16 +79,22 @@ function IntegrationItem({
name,
description,
icon: Icon,
unavailable = false,
}: IntegrationItemProps) {
return (
<SettingsResourceRow
iconVariant='custom'
icon={<IntegrationTile blockType={blockType} icon={Icon} />}
title={name}
description={description || undefined}
description={
unavailable
? 'Unavailable in this deployment. Contact your administrator.'
: description || undefined
}
href={`/workspace/${workspaceId}/integrations/${slug}`}
clickLabel={`Open ${name}`}
navigable
navigable={!unavailable}
disabled={unavailable}
/>
)
}
@@ -133,6 +141,7 @@ export function Integrations() {
const scrollContainerRef = useRef<HTMLDivElement>(null)
const params = useParams()
const workspaceId = (params?.workspaceId as string) || ''
const { integrationAvailability } = usePermissionConfig()
const [{ category: selectedCategory, search: urlSearchTerm }, setIntegrationFilters] =
useQueryStates(integrationsParsers, integrationsUrlKeys)
@@ -341,6 +350,9 @@ export function Integrations() {
{section.integrations.map((integration) => {
const Icon = blockTypeToIconMap[integration.type]
if (!Icon) return null
const availability = integrationAvailability.get(integration.type.toLowerCase())
const deploymentUnavailable =
availability?.state === 'unavailable' || availability?.state === 'misconfigured'
return (
<IntegrationItem
key={integration.type}
@@ -350,6 +362,7 @@ export function Integrations() {
name={integration.name}
description={integration.description}
icon={Icon}
unavailable={integration.authType === 'oauth' && deploymentUnavailable}
/>
)
})}
@@ -90,6 +90,8 @@ interface SettingsResourceRowProps {
* the bleed would force a horizontal scrollbar.
*/
flush?: boolean
/** Renders the row as unavailable without an activation target. */
disabled?: boolean
}
/** The one navigation chevron for every settings resource row. */
@@ -131,6 +133,7 @@ export function SettingsResourceRow({
clickLabel,
navigable = false,
flush = false,
disabled = false,
}: SettingsResourceRowProps) {
const describedById = useId()
const isTile = iconVariant === 'tile'
@@ -180,9 +183,13 @@ export function SettingsResourceRow({
// Row geometry is identical whether or not the row is activatable, so a list
// mixing clickable and static rows keeps one height and one inset.
const rowClass = cn('flex items-center justify-between gap-2.5', !flush && '-mx-2 rounded-lg p-2')
const rowClass = cn(
'flex items-center justify-between gap-2.5',
!flush && '-mx-2 rounded-lg p-2',
disabled && 'opacity-50'
)
if (!onClick && !href) {
if (disabled || (!onClick && !href)) {
return (
<div className={rowClass}>
<div className={clusterClass}>{cluster}</div>
@@ -22,6 +22,7 @@ const INTEGRATION_BASES: readonly {
bgColor: string
slug: string
authType: string
blockType: string
}[] = INTEGRATIONS.flatMap((integration) => {
const icon = blockTypeToIconMap[integration.type]
if (!icon) return []
@@ -33,6 +34,7 @@ const INTEGRATION_BASES: readonly {
bgColor: integration.bgColor,
slug: integration.slug,
authType: integration.authType,
blockType: integration.type,
},
]
})
@@ -43,10 +45,16 @@ const INTEGRATION_BASES: readonly {
* the connect modal auto-opens (via the detail page's `useEffect` on
* `CONNECT_QUERY_PARAM`). Non-OAuth integrations link to the plain detail page.
*/
export function buildIntegrationSearchItems(workspaceId: string): IntegrationSearchItem[] {
return INTEGRATION_BASES.map((base) => {
const connectSuffix =
base.authType === 'oauth' ? `?${CONNECT_QUERY_PARAM}=${CONNECT_MODE.oauth}` : ''
export function buildIntegrationSearchItems(
workspaceId: string,
isBlockAllowed: (blockType: string) => boolean = () => true,
getConnectMode: (
blockType: string
) => (typeof CONNECT_MODE)[keyof typeof CONNECT_MODE] | null = () => CONNECT_MODE.oauth
): IntegrationSearchItem[] {
return INTEGRATION_BASES.filter((base) => isBlockAllowed(base.blockType)).map((base) => {
const connectMode = base.authType === 'oauth' ? getConnectMode(base.blockType) : null
const connectSuffix = connectMode ? `?${CONNECT_QUERY_PARAM}=${connectMode}` : ''
return {
id: base.id,
name: base.name,
@@ -42,6 +42,7 @@ import { isChatEnabled } from '@/lib/core/config/env-flags'
import { isMacPlatform } from '@/lib/core/utils/platform'
import { buildFolderTree, getFolderPathNames } from '@/lib/folders/tree'
import { captureEvent } from '@/lib/posthog/client'
import { CONNECT_MODE } from '@/app/workspace/[workspaceId]/integrations/connect-route'
import { useRegisterGlobalCommands } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation'
@@ -412,7 +413,12 @@ export const Sidebar = memo(function Sidebar({
const posthog = usePostHog()
const { data: sessionData, isPending: sessionLoading } = useSession()
const { canEdit, isLoading: permissionsLoading } = useUserPermissionsContext()
const { config: permissionConfig, filterBlocks } = usePermissionConfig()
const {
config: permissionConfig,
filterBlocks,
isBlockAllowed,
integrationAvailability,
} = usePermissionConfig()
const { navigateToSettings } = useSettingsNavigation()
const initializeSearchData = useSearchModalStore((state) => state.initializeData)
const customBlockOverlayVersion = useCustomBlockOverlayVersion()
@@ -1067,8 +1073,17 @@ export const Sidebar = memo(function Sidebar({
})
const searchModalIntegrations = useMemo(
() => (permissionConfig.hideIntegrationsTab ? [] : buildIntegrationSearchItems(workspaceId)),
[workspaceId, permissionConfig.hideIntegrationsTab]
() =>
permissionConfig.hideIntegrationsTab
? []
: buildIntegrationSearchItems(workspaceId, isBlockAllowed, (blockType) => {
const availability = integrationAvailability.get(blockType.toLowerCase())
if (!availability) return CONNECT_MODE.oauth
if (availability?.oauthAvailable) return CONNECT_MODE.oauth
if (availability?.state === 'limited') return CONNECT_MODE.serviceAccount
return null
}),
[workspaceId, permissionConfig.hideIntegrationsTab, isBlockAllowed, integrationAvailability]
)
const searchModalConnectedAccounts = useMemo(
@@ -1,7 +1,8 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { resetEnvMock, setEnv } from '@sim/testing'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockWithPiSandbox,
@@ -68,6 +69,8 @@ import { BABYSIT_ROUND_PATH } from '@/executor/handlers/pi/babysit-round'
import type { PiBabysitContinuationParams } from '@/executor/handlers/pi/backend'
import { DIFF_PATH } from '@/executor/handlers/pi/cloud-shared'
afterAll(resetEnvMock)
const OLD_SHA = 'a'.repeat(40)
const NEW_SHA = 'c'.repeat(40)
const SECOND_SHA = 'd'.repeat(40)
@@ -235,6 +238,7 @@ function makeRunner(options: {
describe('runBabysitPiWithOptions', () => {
beforeEach(() => {
vi.clearAllMocks()
setEnv({ SANDBOX_PROVIDER: 'e2b' })
mockWithPiSandbox.mockReset()
mockFetchSnapshot.mockReset()
mockFetchThreads.mockReset()
+45 -39
View File
@@ -3,15 +3,25 @@
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useParams } from 'next/navigation'
import { ApiClientError } from '@/lib/api/client/errors'
import { requestJson } from '@/lib/api/client/request'
import { getAllowedIntegrationsContract } from '@/lib/api/contracts/common'
import {
type GetAllowedIntegrationsResponse,
getAllowedIntegrationsContract,
type IntegrationAvailabilityResponse,
} from '@/lib/api/contracts/common'
import { getEnv, isTruthy } from '@/lib/core/config/env'
import {
isDeploymentGatedIntegrationType,
resolveIntegrationAvailabilityStateForVisibility,
} from '@/lib/integrations/availability'
import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access'
import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist'
import {
DEFAULT_PERMISSION_GROUP_CONFIG,
type PermissionGroupConfig,
} from '@/lib/permission-groups/types'
import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay'
import { overlayVisibility } from '@/blocks/visibility/context'
import { useUserPermissionConfig } from '@/ee/access-control/hooks/permission-groups'
export interface PermissionConfigResult {
@@ -26,10 +36,7 @@ export interface PermissionConfigResult {
isToolAllowed: (toolId: string) => boolean
isInvitationsDisabled: boolean
isPublicApiDisabled: boolean
}
interface AllowedIntegrationsResponse {
allowedIntegrations: string[] | null
integrationAvailability: ReadonlyMap<string, IntegrationAvailabilityResponse>
}
const allowedIntegrationsKeys = {
@@ -38,37 +45,17 @@ const allowedIntegrationsKeys = {
}
function useAllowedIntegrationsFromEnv() {
return useQuery<AllowedIntegrationsResponse>({
return useQuery<GetAllowedIntegrationsResponse>({
queryKey: allowedIntegrationsKeys.env(),
queryFn: async ({ signal }) => {
try {
return await requestJson(getAllowedIntegrationsContract, { signal })
} catch (error) {
// Treat any auth/server failure as "no env allowlist configured"
// so the UI falls back to the permission-group-driven allowlist.
if (error instanceof ApiClientError) {
return { allowedIntegrations: null }
}
throw error
}
},
queryFn: ({ signal }) => requestJson(getAllowedIntegrationsContract, { signal }),
staleTime: 5 * 60 * 1000,
})
}
/**
* Intersects two allowlists. If either is null (unrestricted), returns the other.
* If both are set, returns only items present in both.
*/
function intersectAllowlists(a: string[] | null, b: string[] | null): string[] | null {
if (a === null) return b
if (b === null) return a.map((i) => i.toLowerCase())
return a.map((i) => i.toLowerCase()).filter((i) => b.includes(i))
}
export function usePermissionConfig(): PermissionConfigResult {
const params = useParams()
const workspaceId = typeof params?.workspaceId === 'string' ? params.workspaceId : undefined
const blockOverlayVersion = useCustomBlockOverlayVersion()
const { data: permissionData, isLoading: isPermissionLoading } =
useUserPermissionConfig(workspaceId)
@@ -88,16 +75,38 @@ export function usePermissionConfig(): PermissionConfigResult {
const mergedAllowedIntegrations = useMemo(() => {
const envAllowlist = envAllowlistData?.allowedIntegrations ?? null
return intersectAllowlists(config.allowedIntegrations, envAllowlist)
return intersectIntegrationAllowlists(config.allowedIntegrations, envAllowlist)
}, [config.allowedIntegrations, envAllowlistData])
const integrationAvailability = useMemo(() => {
const visibility = overlayVisibility()
return new Map(
(envAllowlistData?.integrationAvailability ?? []).map((availability) => [
availability.type.toLowerCase(),
{
...availability,
state: resolveIntegrationAvailabilityStateForVisibility(availability, visibility),
},
])
)
}, [envAllowlistData?.integrationAvailability, blockOverlayVersion])
const isBlockAllowed = useMemo(() => {
return (blockType: string) => {
const normalizedBlockType = blockType.toLowerCase()
const availability = integrationAvailability.get(normalizedBlockType)
if (
isDeploymentGatedIntegrationType(normalizedBlockType) &&
availability &&
(availability.state === 'unavailable' || availability.state === 'misconfigured')
) {
return false
}
if (isBlockTypeAccessControlExempt(blockType)) return true
if (mergedAllowedIntegrations === null) return true
return mergedAllowedIntegrations.includes(blockType.toLowerCase())
return mergedAllowedIntegrations.includes(normalizedBlockType)
}
}, [mergedAllowedIntegrations])
}, [integrationAvailability, mergedAllowedIntegrations])
const isProviderAllowed = useMemo(() => {
return (providerId: string) => {
@@ -123,14 +132,9 @@ export function usePermissionConfig(): PermissionConfigResult {
const filterBlocks = useMemo(() => {
return <T extends { type: string }>(blocks: T[]): T[] => {
if (mergedAllowedIntegrations === null) return blocks
return blocks.filter(
(block) =>
isBlockTypeAccessControlExempt(block.type) ||
mergedAllowedIntegrations.includes(block.type.toLowerCase())
)
return blocks.filter((block) => isBlockAllowed(block.type))
}
}, [mergedAllowedIntegrations])
}, [isBlockAllowed])
const filterProviders = useMemo(() => {
return (providerIds: string[]): string[] => {
@@ -167,6 +171,7 @@ export function usePermissionConfig(): PermissionConfigResult {
isToolAllowed,
isInvitationsDisabled,
isPublicApiDisabled,
integrationAvailability,
}),
[
mergedConfig,
@@ -180,6 +185,7 @@ export function usePermissionConfig(): PermissionConfigResult {
isToolAllowed,
isInvitationsDisabled,
isPublicApiDisabled,
integrationAvailability,
]
)
}
+13
View File
@@ -57,6 +57,14 @@ export const getAllowedProvidersContract = defineRouteContract({
},
})
export const integrationAvailabilitySchema = z.object({
type: z.string().min(1),
state: z.enum(['ready', 'limited', 'unavailable', 'misconfigured']),
oauthAvailable: z.boolean(),
})
export type IntegrationAvailabilityResponse = z.output<typeof integrationAvailabilitySchema>
export const getAllowedIntegrationsContract = defineRouteContract({
method: 'GET',
path: '/api/settings/allowed-integrations',
@@ -66,10 +74,15 @@ export const getAllowedIntegrationsContract = defineRouteContract({
// `null` means "no env-derived allowlist" (unrestricted); a non-null
// array narrows the visible integrations.
allowedIntegrations: z.array(z.string()).nullable(),
integrationAvailability: z.array(integrationAvailabilitySchema),
}),
},
})
export type GetAllowedIntegrationsResponse = z.output<
typeof getAllowedIntegrationsContract.response.schema
>
export const getVoiceSettingsContract = defineRouteContract({
method: 'GET',
path: '/api/settings/voice',
+6 -1
View File
@@ -6,6 +6,7 @@ import { generateId } from '@sim/utils/id'
import type { GenericOAuthConfig } from 'better-auth/plugins'
import { syntheticConnectorEmail } from '@/lib/auth/connector-email'
import { env } from '@/lib/core/config/env'
import { inspectConfiguredOAuthClient } from '@/lib/core/config/env-capabilities.server'
import {
readResponseJsonWithLimit,
readResponseTextWithLimit,
@@ -90,7 +91,7 @@ interface AttioWorkspaceMemberResponse {
* `any`.
*/
export function buildConnectorProviders(): GenericOAuthConfig[] {
return [
const providers: GenericOAuthConfig[] = [
{
providerId: 'google-email',
clientId: env.GOOGLE_CLIENT_ID as string,
@@ -2407,4 +2408,8 @@ export function buildConnectorProviders(): GenericOAuthConfig[] {
},
},
]
return providers.filter(
({ providerId }) => inspectConfiguredOAuthClient(providerId).state === 'ready'
)
}
+96 -8
View File
@@ -1,15 +1,24 @@
/**
* @vitest-environment node
*/
import { workflowsUtilsMock } from '@sim/testing'
import { envFlagsMockFns, resetEnvFlagsMock, workflowsUtilsMock } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockCreateUserToolSchema, mockGetHighestPrioritySubscription, mockTrackChatUpload } =
vi.hoisted(() => ({
mockCreateUserToolSchema: vi.fn(() => ({ type: 'object', properties: {} })),
mockGetHighestPrioritySubscription: vi.fn(),
mockTrackChatUpload: vi.fn(),
}))
const {
mockCreateUserToolSchema,
mockGetHighestPrioritySubscription,
mockGetUserPermissionConfig,
mockIsIntegrationDeploymentAvailable,
mockIsOAuthServiceDeploymentAvailable,
mockTrackChatUpload,
} = vi.hoisted(() => ({
mockCreateUserToolSchema: vi.fn(() => ({ type: 'object', properties: {} })),
mockGetHighestPrioritySubscription: vi.fn(),
mockGetUserPermissionConfig: vi.fn(),
mockIsIntegrationDeploymentAvailable: vi.fn(() => true),
mockIsOAuthServiceDeploymentAvailable: vi.fn(() => true),
mockTrackChatUpload: vi.fn(),
}))
vi.mock('@/lib/billing/core/subscription', () => ({
getHighestPrioritySubscription: mockGetHighestPrioritySubscription,
@@ -65,7 +74,13 @@ vi.mock('@/lib/copilot/block-visibility', () => ({
}))
vi.mock('@/lib/copilot/integration-tools', () => ({
filterExposedIntegrationTools: vi.fn((tools: unknown[]) => tools),
filterExposedIntegrationTools: vi.fn(
(
tools: Array<{ blockType: string; service: string }>,
_vis: unknown,
isOwnerAllowed: (owner: { blockType: string; service: string }) => boolean
) => tools.filter((tool) => isOwnerAllowed(tool))
),
getExposedIntegrationTools: vi.fn(() => [
{
toolId: 'gmail_send',
@@ -78,6 +93,7 @@ vi.mock('@/lib/copilot/integration-tools', () => ({
},
service: 'gmail',
operation: 'send',
blockType: 'gmail',
},
{
toolId: 'brandfetch_search',
@@ -88,6 +104,7 @@ vi.mock('@/lib/copilot/integration-tools', () => ({
},
service: 'brandfetch',
operation: 'search',
blockType: 'brandfetch',
},
{
toolId: 'run_workflow',
@@ -98,6 +115,7 @@ vi.mock('@/lib/copilot/integration-tools', () => ({
},
service: 'run',
operation: 'workflow',
blockType: 'run',
},
]),
}))
@@ -110,6 +128,15 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
trackChatUpload: mockTrackChatUpload,
}))
vi.mock('@/lib/integrations/availability.server', () => ({
isIntegrationDeploymentAvailableForVisibility: mockIsIntegrationDeploymentAvailable,
isOAuthServiceDeploymentAvailable: mockIsOAuthServiceDeploymentAvailable,
}))
vi.mock('@/ee/access-control/utils/permission-check', () => ({
getUserPermissionConfig: mockGetUserPermissionConfig,
}))
import {
buildCopilotRequestPayload,
buildIntegrationToolSchemas,
@@ -119,8 +146,12 @@ import {
describe('buildIntegrationToolSchemas', () => {
beforeEach(() => {
vi.clearAllMocks()
resetEnvFlagsMock()
clearIntegrationToolSchemaCacheForTests()
mockCreateUserToolSchema.mockReturnValue({ type: 'object', properties: {} })
mockIsIntegrationDeploymentAvailable.mockReturnValue(true)
mockIsOAuthServiceDeploymentAvailable.mockReturnValue(true)
mockGetUserPermissionConfig.mockResolvedValue(null)
})
it('appends the email footer prompt for free users', async () => {
@@ -197,6 +228,63 @@ describe('buildIntegrationToolSchemas', () => {
)
})
it('removes tools whose canonical exposed block is unavailable', async () => {
mockGetHighestPrioritySubscription.mockResolvedValue({ plan: 'pro', status: 'active' })
mockIsIntegrationDeploymentAvailable.mockImplementation((blockType: string) => {
return blockType !== 'gmail'
})
const toolSchemas = await buildIntegrationToolSchemas('user-deployment-filter')
expect(toolSchemas.some((tool) => tool.name === 'gmail_send')).toBe(false)
expect(toolSchemas.some((tool) => tool.name === 'brandfetch_search')).toBe(true)
})
it('intersects workspace and deployment integration allowlists', async () => {
mockGetHighestPrioritySubscription.mockResolvedValue({ plan: 'pro', status: 'active' })
mockGetUserPermissionConfig.mockResolvedValue({
allowedIntegrations: ['gmail', 'brandfetch'],
})
envFlagsMockFns.getAllowedIntegrationsFromEnv.mockReturnValue(['brandfetch'])
const toolSchemas = await buildIntegrationToolSchemas(
'user-intersection',
undefined,
{ schemaSurface: 'copilot' },
'workspace-1'
)
expect(toolSchemas.some((tool) => tool.name === 'gmail_send')).toBe(false)
expect(toolSchemas.some((tool) => tool.name === 'brandfetch_search')).toBe(true)
})
it('keeps a limited integration callable without advertising OAuth', async () => {
mockGetHighestPrioritySubscription.mockResolvedValue({ plan: 'pro', status: 'active' })
mockIsOAuthServiceDeploymentAvailable.mockImplementation(
(providerId: string) => providerId !== 'google-email'
)
const toolSchemas = await buildIntegrationToolSchemas('user-limited-integration')
const gmailTool = toolSchemas.find((tool) => tool.name === 'gmail_send')
expect(gmailTool).toBeDefined()
expect(gmailTool).not.toHaveProperty('oauth')
})
it('fails closed when workspace integration permissions cannot be loaded', async () => {
mockGetUserPermissionConfig.mockRejectedValue(new Error('permission backend unavailable'))
await expect(
buildIntegrationToolSchemas(
'user-permission-error',
undefined,
{ schemaSurface: 'copilot' },
'workspace-1'
)
).rejects.toThrow('permission backend unavailable')
expect(mockCreateUserToolSchema).not.toHaveBeenCalled()
})
it('briefly reuses built schemas for the same user and surface', async () => {
mockGetHighestPrioritySubscription.mockResolvedValue({ plan: 'pro', status: 'active' })
+41 -46
View File
@@ -16,10 +16,19 @@ import { getToolEntry } from '@/lib/copilot/tool-executor/router'
import { getCopilotToolDescription } from '@/lib/copilot/tools/descriptions'
import { encodeVfsSegment } from '@/lib/copilot/vfs/path-utils'
import type { BlockVisibilityState } from '@/lib/core/config/block-visibility'
import { isDocSandboxEnabled, isHosted } from '@/lib/core/config/env-flags'
import { EnvCapabilityConfigurationError } from '@/lib/core/config/env-capabilities'
import {
getAllowedIntegrationsFromEnv,
isDocSandboxEnabled,
isHosted,
} from '@/lib/core/config/env-flags'
import {
isIntegrationDeploymentAvailableForVisibility,
isOAuthServiceDeploymentAvailable,
} from '@/lib/integrations/availability.server'
import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist'
import { trackChatUpload } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { buildArchiveExtractGuidance, isArchiveFileName } from '@/lib/uploads/utils/file-utils'
import { stripVersionSuffix } from '@/tools/utils'
const logger = createLogger('CopilotChatPayload')
const INTEGRATION_TOOL_SCHEMA_CACHE_TTL_MS = 5_000
@@ -187,6 +196,19 @@ async function buildIntegrationToolSchemasUncached(
): Promise<ToolSchema[]> {
const reqLogger = logger.withMetadata({ messageId })
const integrationTools: ToolSchema[] = []
let allowedIntegrations = getAllowedIntegrationsFromEnv()
if (workspaceId) {
const { getUserPermissionConfig } = await import('@/ee/access-control/utils/permission-check')
const permissionConfig = await getUserPermissionConfig(userId, workspaceId)
allowedIntegrations = intersectIntegrationAllowlists(
permissionConfig?.allowedIntegrations ?? null,
allowedIntegrations
)
}
const allowedIntegrationTypes = allowedIntegrations
? new Set(allowedIntegrations.map((integration) => integration.toLowerCase()))
: null
try {
const { createUserToolSchema } = await import('@/tools/params')
let shouldAppendEmailTagline = false
@@ -201,46 +223,16 @@ async function buildIntegrationToolSchemasUncached(
})
}
let allowedIntegrations: Set<string> | null = null
let toolIdToBlockType: Map<string, string> | null = null
if (workspaceId) {
try {
const [{ getUserPermissionConfig }, { getAllBlocks }] = await Promise.all([
import('@/ee/access-control/utils/permission-check'),
import('@/blocks/registry'),
])
const permissionConfig = await getUserPermissionConfig(userId, workspaceId)
if (permissionConfig?.allowedIntegrations) {
allowedIntegrations = new Set(
permissionConfig.allowedIntegrations.map((i) => i.toLowerCase())
)
toolIdToBlockType = new Map()
for (const blockConfig of getAllBlocks()) {
const access = blockConfig.tools?.access
if (!access) continue
for (const toolId of access) {
toolIdToBlockType.set(stripVersionSuffix(toolId), blockConfig.type.toLowerCase())
}
}
}
} catch (error) {
reqLogger.warn('Failed to load permission config for tool schema filter', {
userId,
workspaceId,
error: toError(error).message,
})
}
}
const exposedTools = filterExposedIntegrationTools(getExposedIntegrationTools(), vis)
const exposedTools = filterExposedIntegrationTools(
getExposedIntegrationTools(),
vis,
(owner) =>
isIntegrationDeploymentAvailableForVisibility(owner.blockType, vis) &&
(allowedIntegrationTypes === null ||
allowedIntegrationTypes.has(owner.blockType.toLowerCase()))
)
for (const { toolId, config: toolConfig, service, operation } of exposedTools) {
try {
if (allowedIntegrations && toolIdToBlockType) {
const owningBlock = toolIdToBlockType.get(stripVersionSuffix(toolId))
if (owningBlock && !allowedIntegrations.has(owningBlock)) {
continue
}
}
const userSchema = createUserToolSchema(toolConfig, {
surface: options.schemaSurface,
// On hosted deployments the executor injects hosted keys server-side,
@@ -272,14 +264,16 @@ async function buildIntegrationToolSchemasUncached(
defer_loading: true,
executeLocally:
catalogEntry?.clientExecutable === true || catalogEntry?.route === 'client',
...(toolConfig.oauth?.required && {
oauth: {
required: true,
provider: toolConfig.oauth.provider,
},
}),
...(toolConfig.oauth?.required &&
isOAuthServiceDeploymentAvailable(toolConfig.oauth.provider) && {
oauth: {
required: true,
provider: toolConfig.oauth.provider,
},
}),
})
} catch (toolError) {
if (toolError instanceof EnvCapabilityConfigurationError) throw toolError
logger.warn(
messageId
? `Failed to build schema for tool, skipping [messageId:${messageId}]`
@@ -292,6 +286,7 @@ async function buildIntegrationToolSchemasUncached(
}
}
} catch (error) {
if (error instanceof EnvCapabilityConfigurationError) throw error
logger.warn(
messageId
? `Failed to build tool schemas [messageId:${messageId}]`
@@ -10,15 +10,36 @@ import {
} from '@/lib/copilot/chat/selection-context'
import type { ChatContext } from '@/stores/panel'
const { discoverServerTools, getSkillById, getWorkspaceFile, getTableById, getRowsByIds } =
vi.hoisted(() => ({
discoverServerTools: vi.fn(),
getSkillById: vi.fn(),
getWorkspaceFile: vi.fn(),
getTableById: vi.fn(),
getRowsByIds: vi.fn(),
}))
const {
discoverServerTools,
getBlock,
getBlockRegistry,
getSkillById,
getUserPermissionConfig,
getWorkspaceFile,
getTableById,
getRowsByIds,
getBlockVisibilityForCopilot,
isIntegrationDeploymentAvailable,
} = vi.hoisted(() => ({
discoverServerTools: vi.fn(),
getBlock: vi.fn(),
getBlockRegistry: vi.fn(),
getSkillById: vi.fn(),
getUserPermissionConfig: vi.fn(),
getWorkspaceFile: vi.fn(),
getTableById: vi.fn(),
getRowsByIds: vi.fn(),
getBlockVisibilityForCopilot: vi.fn(async () => null),
isIntegrationDeploymentAvailable: vi.fn(() => true),
}))
vi.mock('@/blocks/registry', () => ({ getBlock, getBlockRegistry }))
vi.mock('@/lib/copilot/block-visibility', () => ({ getBlockVisibilityForCopilot }))
vi.mock('@/ee/access-control/utils/permission-check', () => ({ getUserPermissionConfig }))
vi.mock('@/lib/integrations/availability.server', () => ({
isIntegrationDeploymentAvailableForVisibility: isIntegrationDeploymentAvailable,
}))
vi.mock('@/lib/workflows/skills/operations', () => ({ getSkillById }))
vi.mock('@/lib/mcp/service', () => ({ mcpService: { discoverServerTools } }))
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ getWorkspaceFile }))
@@ -32,6 +53,42 @@ vi.mock('@/lib/table/rows/service', () => ({ getRowsByIds }))
import { processContextsServer } from './process-contents'
describe('processContextsServer - block contexts', () => {
beforeEach(() => {
vi.clearAllMocks()
const blocks = {
start_trigger: { type: 'start_trigger', hideFromToolbar: false },
slack: { type: 'slack', hideFromToolbar: false },
notion: { type: 'notion', hideFromToolbar: false },
}
getBlockRegistry.mockReturnValue(blocks)
getBlock.mockImplementation((type: string) => blocks[type as keyof typeof blocks])
getUserPermissionConfig.mockResolvedValue({ allowedIntegrations: ['slack'] })
isIntegrationDeploymentAvailable.mockReturnValue(true)
})
it('keeps access-control-exempt blocks while filtering non-exempt integrations', async () => {
const result = await processContextsServer(
[
{ kind: 'blocks', blockIds: ['start_trigger'], label: 'Start' } as ChatContext,
{ kind: 'blocks', blockIds: ['notion'], label: 'Notion' } as ChatContext,
],
'user-1',
'hello',
'workspace-1'
)
expect(result).toEqual([
{
type: 'blocks',
tag: '@Start',
content: '',
path: 'components/blocks/start_trigger.json',
},
])
})
})
describe('processContextsServer - skill contexts', () => {
beforeEach(() => {
vi.clearAllMocks()
+23 -5
View File
@@ -6,6 +6,7 @@ import {
getActiveWorkflowRecord,
} from '@sim/platform-authz/workflow'
import { and, eq, isNull } from 'drizzle-orm'
import { getBlockVisibilityForCopilot } from '@/lib/copilot/block-visibility'
import {
MAX_TABLE_SELECTION_CONTENT_LENGTH,
safeBrowserSelectionUrl,
@@ -22,11 +23,15 @@ import {
encodeVfsPathSegments,
encodeVfsSegment,
} from '@/lib/copilot/vfs/path-utils'
import { EnvCapabilityConfigurationError } from '@/lib/core/config/env-capabilities'
import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags'
import { isIntegrationDeploymentAvailableForVisibility } from '@/lib/integrations/availability.server'
import { toOverview } from '@/lib/logs/log-views'
import type { TraceSpan } from '@/lib/logs/types'
import { mcpService } from '@/lib/mcp/service'
import { createMcpToolId } from '@/lib/mcp/utils'
import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access'
import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist'
import { getColumnId } from '@/lib/table/column-keys'
import { getRowsByIds } from '@/lib/table/rows/service'
import { getTableById } from '@/lib/table/service'
@@ -598,11 +603,23 @@ async function processBlockMetadata(
workspaceId?: string
): Promise<AgentContext | null> {
try {
const permissionConfig =
userId && workspaceId ? await getUserPermissionConfig(userId, workspaceId) : null
const allowedIntegrations =
permissionConfig?.allowedIntegrations ?? getAllowedIntegrationsFromEnv()
if (allowedIntegrations != null && !allowedIntegrations.includes(blockId.toLowerCase())) {
const [permissionConfig, visibility] = await Promise.all([
userId && workspaceId ? getUserPermissionConfig(userId, workspaceId) : null,
userId ? getBlockVisibilityForCopilot(userId, workspaceId) : null,
])
const allowedIntegrations = intersectIntegrationAllowlists(
permissionConfig?.allowedIntegrations ?? null,
getAllowedIntegrationsFromEnv()
)
if (!isIntegrationDeploymentAvailableForVisibility(blockId, visibility)) {
logger.debug('Block unavailable for this deployment', { blockId })
return null
}
if (
allowedIntegrations != null &&
!isBlockTypeAccessControlExempt(blockId) &&
!allowedIntegrations.includes(blockId.toLowerCase())
) {
logger.debug('Block not allowed by integration allowlist', { blockId, userId })
return null
}
@@ -615,6 +632,7 @@ async function processBlockMetadata(
return { type: 'blocks', tag, content: '', path: canonicalBlockVfsPath(blockId) }
} catch (error) {
if (error instanceof EnvCapabilityConfigurationError) throw error
logger.error('Error processing block metadata', { blockId, error })
return null
}
+19 -1
View File
@@ -10,7 +10,7 @@ vi.mock('@/blocks/registry-maps', () => ({
tools: { access: ['svc_send_v2'] },
},
// Preview successor sharing the released block's tools (the slack/slack_v2
// paradigm) — must not become the owner of the shared tools.
// paradigm) — both owners must remain available for projection.
svc_v2: {
type: 'svc_v2',
preview: true,
@@ -49,6 +49,7 @@ describe('getExposedIntegrationTools', () => {
expect(send).toBeDefined()
expect(send?.blockType).toBe('svc')
expect(send?.preview).toBeFalsy()
expect(send?.owners.map((owner) => owner.blockType)).toEqual(['svc', 'svc_v2'])
})
it('exposes shared tools to viewers without the preview reveal, but not preview-only tools', () => {
@@ -57,6 +58,23 @@ describe('getExposedIntegrationTools', () => {
expect(visible.some((t) => t.toolId === 'newsvc_do_v1')).toBe(false)
})
it('uses a revealed preview owner when the released owner is unavailable', () => {
const vis = {
revealed: new Set(['svc_v2']),
disabled: new Set<string>(),
previewTagged: new Set(['svc_v2']),
}
const visible = filterExposedIntegrationTools(
getExposedIntegrationTools(),
vis,
(owner) => owner.blockType !== 'svc'
)
const send = visible.find((tool) => tool.toolId === 'svc_send_v2')
expect(send?.blockType).toBe('svc_v2')
expect(send?.preview).toBe(true)
})
it('exposes only the latest version of each tool', () => {
const exposed = getExposedIntegrationTools()
expect(exposed.some((t) => t.toolId === 'svc_send_v1')).toBe(false)
+32 -15
View File
@@ -5,7 +5,13 @@ import { tools as toolRegistry } from '@/tools/registry'
import type { ToolConfig } from '@/tools/types'
import { getLatestVersionTools, stripVersionSuffix } from '@/tools/utils'
export interface ExposedIntegrationTool {
export interface ExposedIntegrationToolOwner {
service: string
blockType: string
preview?: boolean
}
export interface ExposedIntegrationTool extends ExposedIntegrationToolOwner {
/**
* Full registry tool id also the agent-callable id and the schema `id`
* field (e.g. gmail_read_v2). No stripping: discovery, the schema id, and the
@@ -21,6 +27,8 @@ export interface ExposedIntegrationTool {
blockType: string
/** Owning block's static `preview` marker, for the per-viewer filter. */
preview?: boolean
/** Every visible block that declares this tool, including preview successors. */
owners: readonly ExposedIntegrationToolOwner[]
}
let cached: ExposedIntegrationTool[] | null = null
@@ -46,7 +54,7 @@ export function getExposedIntegrationTools(): ExposedIntegrationTool[] {
// Map the tool ids each visible block exposes (both the raw id and its
// version-stripped base name) to that block's service directory + type.
const toolToBlock = new Map<string, { service: string; blockType: string; preview?: boolean }>()
const toolToBlocks = new Map<string, ExposedIntegrationToolOwner[]>()
for (const block of Object.values(BLOCK_REGISTRY)) {
if (block.hideFromToolbar) continue
if (!block.tools?.access) continue
@@ -54,13 +62,14 @@ export function getExposedIntegrationTools(): ExposedIntegrationTool[] {
const owner = { service, blockType: block.type, preview: block.preview }
for (const toolId of block.tools.access) {
for (const key of [toolId, stripVersionSuffix(toolId)]) {
// A preview block must not steal ownership of tools it shares with a
// released block (e.g. slack_v2 spreads slack's tools.access), or the
// per-viewer filter would hide those tools from everyone without the
// preview reveal.
const existing = toolToBlock.get(key)
if (existing && !existing.preview && owner.preview) continue
toolToBlock.set(key, owner)
const owners = toolToBlocks.get(key)
if (owners) {
if (!owners.some((existing) => existing.blockType === owner.blockType)) {
owners.push(owner)
}
} else {
toolToBlocks.set(key, [owner])
}
}
}
}
@@ -69,8 +78,9 @@ export function getExposedIntegrationTools(): ExposedIntegrationTool[] {
const seen = new Set<string>()
for (const [toolId, config] of Object.entries(getLatestVersionTools(toolRegistry))) {
const baseName = stripVersionSuffix(toolId)
const owner = toolToBlock.get(toolId) ?? toolToBlock.get(baseName)
if (!owner) continue
const owners = toolToBlocks.get(toolId) ?? toolToBlocks.get(baseName)
if (!owners || owners.length === 0) continue
const owner = owners.find((candidate) => !candidate.preview) ?? owners[0]
if (seen.has(baseName)) continue
seen.add(baseName)
const prefix = `${owner.service}_`
@@ -82,6 +92,7 @@ export function getExposedIntegrationTools(): ExposedIntegrationTool[] {
operation,
blockType: owner.blockType,
preview: owner.preview,
owners,
})
}
@@ -97,11 +108,17 @@ export function getExposedIntegrationTools(): ExposedIntegrationTool[] {
*/
export function filterExposedIntegrationTools(
tools: ExposedIntegrationTool[],
vis: BlockVisibilityState | null
vis: BlockVisibilityState | null,
isOwnerAllowed: (owner: ExposedIntegrationToolOwner) => boolean = () => true
): ExposedIntegrationTool[] {
return tools.filter(
(tool) => !isHiddenUnder(vis, { type: tool.blockType, preview: tool.preview })
)
return tools.flatMap((tool) => {
const owner = tool.owners.find(
(candidate) =>
!isHiddenUnder(vis, { type: candidate.blockType, preview: candidate.preview }) &&
isOwnerAllowed(candidate)
)
return owner ? [{ ...tool, ...owner }] : []
})
}
/** Test-only: clears the memoized set so registry changes are picked up. */
@@ -4,6 +4,10 @@ import {
getExposedIntegrationTools,
} from '@/lib/copilot/integration-tools'
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags'
import { isIntegrationDeploymentAvailableForVisibility } from '@/lib/integrations/availability.server'
import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
import { stripVersionSuffix } from '@/tools/utils'
export async function executeListIntegrationTools(
@@ -18,7 +22,20 @@ export async function executeListIntegrationTools(
// The exposed set is the ungated universe — project it for this viewer so
// gated (preview / kill-switched) integrations stay undiscoverable.
const vis = await getBlockVisibilityForCopilot(context.userId, context.workspaceId)
const all = filterExposedIntegrationTools(getExposedIntegrationTools(), vis)
const permissionConfig = context.workspaceId
? await getUserPermissionConfig(context.userId, context.workspaceId)
: null
const allowedIntegrations = intersectIntegrationAllowlists(
permissionConfig?.allowedIntegrations ?? null,
getAllowedIntegrationsFromEnv()
)
const all = filterExposedIntegrationTools(
getExposedIntegrationTools(),
vis,
(owner) =>
isIntegrationDeploymentAvailableForVisibility(owner.blockType, vis) &&
(allowedIntegrations === null || allowedIntegrations.includes(owner.blockType.toLowerCase()))
)
const service = stripVersionSuffix(raw.toLowerCase())
const matches = all.filter((tool) => tool.service === service)
@@ -3,9 +3,16 @@
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockEnsureWorkspaceAccess, mockGetCredentialActorContext } = vi.hoisted(() => ({
const {
mockEnsureWorkspaceAccess,
mockGetCredentialActorContext,
mockIsOAuthServiceDeploymentAvailable,
mockGetUserPermissionConfig,
} = vi.hoisted(() => ({
mockEnsureWorkspaceAccess: vi.fn(),
mockGetCredentialActorContext: vi.fn(),
mockIsOAuthServiceDeploymentAvailable: vi.fn(() => true),
mockGetUserPermissionConfig: vi.fn(),
}))
vi.mock('@/lib/copilot/tools/handlers/access', () => ({
@@ -16,12 +23,30 @@ vi.mock('@/lib/credentials/access', () => ({
getCredentialActorContext: mockGetCredentialActorContext,
}))
vi.mock('@/lib/integrations/availability.server', () => ({
isOAuthServiceDeploymentAvailable: mockIsOAuthServiceDeploymentAvailable,
}))
vi.mock('@/lib/core/config/env-flags', () => ({
getAllowedIntegrationsFromEnv: vi.fn(() => null),
}))
vi.mock('@/ee/access-control/utils/permission-check', () => ({
getUserPermissionConfig: mockGetUserPermissionConfig,
}))
vi.mock('@/lib/oauth/utils', () => ({
getAllOAuthServices: vi.fn(() => [
{ providerId: 'google-email', name: 'Gmail' },
{ providerId: 'slack', name: 'Slack' },
{ providerId: 'trello', name: 'Trello' },
{ providerId: 'shopify', name: 'Shopify' },
{ serviceId: 'gmail', providerId: 'google-email', name: 'Gmail', authType: 'oauth' },
{ serviceId: 'slack', providerId: 'slack', name: 'Slack', authType: 'oauth' },
{ serviceId: 'trello', providerId: 'trello', name: 'Trello', authType: 'oauth' },
{ serviceId: 'shopify', providerId: 'shopify', name: 'Shopify', authType: 'oauth' },
{
serviceId: 'claude-platform',
providerId: 'claude-platform',
name: 'Claude Platform',
authType: 'service_account',
},
]),
}))
@@ -69,6 +94,8 @@ describe('executeOAuthGetAuthLink', () => {
vi.clearAllMocks()
process.env.NEXT_PUBLIC_APP_URL = BASE_URL
mockEnsureWorkspaceAccess.mockResolvedValue(WORKSPACE_ACCESS)
mockIsOAuthServiceDeploymentAvailable.mockReturnValue(true)
mockGetUserPermissionConfig.mockResolvedValue(null)
})
describe('connect (no credentialId)', () => {
@@ -82,6 +109,31 @@ describe('executeOAuthGetAuthLink', () => {
expect(url.searchParams.get('credentialId')).toBeNull()
expect(mockGetCredentialActorContext).not.toHaveBeenCalled()
})
it('rejects a provider whose OAuth client is not configured', async () => {
mockIsOAuthServiceDeploymentAvailable.mockReturnValue(false)
const result = await executeOAuthGetAuthLink({ providerName: 'google-email' }, context)
expect(result.success).toBe(false)
expect(result.error).toContain('not configured for this deployment')
})
it('rejects a provider disallowed for the workspace member', async () => {
mockGetUserPermissionConfig.mockResolvedValue({ allowedIntegrations: ['slack'] })
const result = await executeOAuthGetAuthLink({ providerName: 'google-email' }, context)
expect(result.success).toBe(false)
expect(result.error).toContain('not allowed for this workspace member')
})
it('does not treat service-account-only metadata as OAuth', async () => {
const result = await executeOAuthGetAuthLink({ providerName: 'Claude Platform' }, context)
expect(result.success).toBe(false)
expect(result.error).toContain('not found')
})
})
describe('reconnect (credentialId passed)', () => {
@@ -213,6 +265,8 @@ describe('executeOAuthGetAuthLink service account rejection', () => {
vi.clearAllMocks()
process.env.NEXT_PUBLIC_APP_URL = BASE_URL
mockEnsureWorkspaceAccess.mockResolvedValue(WORKSPACE_ACCESS)
mockIsOAuthServiceDeploymentAvailable.mockReturnValue(true)
mockGetUserPermissionConfig.mockResolvedValue(null)
})
/**
+22 -1
View File
@@ -1,11 +1,16 @@
import { toError } from '@sim/utils/errors'
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access'
import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getCredentialActorContext } from '@/lib/credentials/access'
import { isServiceAccountProviderId } from '@/lib/credentials/service-account-provider-ids'
import { isOAuthServiceAllowedByIntegrationTypes } from '@/lib/integrations/availability'
import { isOAuthServiceDeploymentAvailable } from '@/lib/integrations/availability.server'
import { getAllOAuthServices } from '@/lib/oauth/utils'
import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist'
import type { WorkspaceAccess } from '@/lib/workspaces/permissions/utils'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
export async function executeOAuthGetAuthLink(
rawParams: Record<string, unknown>,
@@ -43,12 +48,21 @@ export async function executeOAuthGetAuthLink(
context.userId,
'write'
)
const permissionConfig = await getUserPermissionConfig(context.userId, context.workspaceId)
const configuredAllowedIntegrations = intersectIntegrationAllowlists(
permissionConfig?.allowedIntegrations ?? null,
getAllowedIntegrationsFromEnv()
)
const allowedIntegrationTypes = configuredAllowedIntegrations
? new Set(configuredAllowedIntegrations.map((type) => type.toLowerCase()))
: null
const result = await generateOAuthLink(
context.workspaceId,
context.workflowId,
context.chatId,
providerName,
baseUrl,
allowedIntegrationTypes,
credentialId ? { credentialId, userId: context.userId, workspaceAccess } : undefined
)
const action = credentialId ? 'reconnect' : 'connect'
@@ -117,13 +131,14 @@ async function generateOAuthLink(
chatId: string | undefined,
providerName: string,
baseUrl: string,
allowedIntegrationTypes: ReadonlySet<string> | null,
reconnect?: { credentialId: string; userId: string; workspaceAccess: WorkspaceAccess }
): Promise<{ url: string; providerId: string; serviceName: string }> {
if (!workspaceId) {
throw new Error('workspaceId is required to generate an OAuth link')
}
const allServices = getAllOAuthServices()
const allServices = getAllOAuthServices().filter((service) => service.authType === 'oauth')
const normalizedInput = providerName.toLowerCase().trim()
const matched =
@@ -144,6 +159,12 @@ async function generateOAuthLink(
}
const { providerId, name: serviceName } = matched
if (!isOAuthServiceAllowedByIntegrationTypes(matched.serviceId, allowedIntegrationTypes)) {
throw new Error(`${serviceName} is not allowed for this workspace member`)
}
if (!isOAuthServiceDeploymentAvailable(providerId)) {
throw new Error(`${serviceName} OAuth is not configured for this deployment`)
}
if (reconnect) {
if (providerId === 'trello' || providerId === 'shopify') {
@@ -2,15 +2,50 @@
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { computeBlockLevelInputs } from '@/lib/copilot/tools/server/blocks/get-blocks-metadata-tool'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockGetUserPermissionConfig, mockIsIntegrationDeploymentAvailable } = vi.hoisted(() => ({
mockGetUserPermissionConfig: vi.fn(),
mockIsIntegrationDeploymentAvailable: vi.fn(() => true),
}))
vi.mock('@/ee/access-control/utils/permission-check', () => ({
getUserPermissionConfig: mockGetUserPermissionConfig,
}))
vi.mock('@/lib/integrations/availability.server', () => ({
isIntegrationDeploymentAvailableForVisibility: mockIsIntegrationDeploymentAvailable,
}))
import {
computeBlockLevelInputs,
getBlocksMetadataServerTool,
} from '@/lib/copilot/tools/server/blocks/get-blocks-metadata-tool'
import { MothershipBlock } from '@/blocks/blocks/mothership'
describe('get blocks metadata', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGetUserPermissionConfig.mockResolvedValue({ allowedIntegrations: ['slack'] })
mockIsIntegrationDeploymentAvailable.mockReturnValue(true)
})
it('omits server-only Mothership policy inputs from block metadata definitions', () => {
const definitions = computeBlockLevelInputs(MothershipBlock)
expect(definitions).not.toHaveProperty('secretScope')
expect(definitions).not.toHaveProperty('mountedSecrets')
})
it('keeps access-control-exempt and special blocks under a restrictive allowlist', async () => {
const result = await getBlocksMetadataServerTool.execute(
{ blockIds: ['start_trigger', 'loop', 'slack', 'notion'] },
{ userId: 'user-1', workspaceId: 'workspace-1' }
)
expect(result.metadata).toHaveProperty('start_trigger')
expect(result.metadata).toHaveProperty('loop')
expect(result.metadata).toHaveProperty('slack')
expect(result.metadata).not.toHaveProperty('notion')
})
})
@@ -6,7 +6,10 @@ import { z } from 'zod'
import { getCopilotToolDescription } from '@/lib/copilot/tools/descriptions'
import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool'
import { getAllowedIntegrationsFromEnv, isHosted } from '@/lib/core/config/env-flags'
import { isIntegrationDeploymentAvailableForVisibility } from '@/lib/integrations/availability.server'
import { getServiceAccountProviderForProviderId } from '@/lib/oauth/utils'
import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access'
import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist'
import { isCustomBlockType } from '@/blocks/custom/build-config'
import { getBlock } from '@/blocks/registry'
import { AuthMode, type BlockConfig, isHiddenFromDisplay } from '@/blocks/types'
@@ -124,20 +127,32 @@ export const getBlocksMetadataServerTool: BaseServerTool<
context?.userId && context?.workspaceId
? await getUserPermissionConfig(context.userId, context.workspaceId)
: null
const allowedIntegrations =
permissionConfig?.allowedIntegrations ?? getAllowedIntegrationsFromEnv()
const allowedIntegrations = intersectIntegrationAllowlists(
permissionConfig?.allowedIntegrations ?? null,
getAllowedIntegrationsFromEnv()
)
const visibility = overlayVisibility()
const result: Record<string, CopilotBlockMetadata> = {}
for (const blockId of blockIds || []) {
if (allowedIntegrations != null && !allowedIntegrations.includes(blockId.toLowerCase())) {
const specialBlock = SPECIAL_BLOCKS_METADATA[blockId]
if (!isIntegrationDeploymentAvailableForVisibility(blockId, visibility)) {
logger.debug('Block unavailable for this deployment', { blockId })
continue
}
if (
allowedIntegrations != null &&
!specialBlock &&
!isBlockTypeAccessControlExempt(blockId) &&
!allowedIntegrations.includes(blockId.toLowerCase())
) {
logger.debug('Block not allowed by permission group', { blockId })
continue
}
let metadata: any
if (SPECIAL_BLOCKS_METADATA[blockId]) {
const specialBlock = SPECIAL_BLOCKS_METADATA[blockId]
if (specialBlock) {
const { commonParameters, operationParameters } = splitParametersByOperation(
specialBlock.subBlocks || [],
specialBlock.inputs || {}
@@ -170,7 +185,7 @@ export const getBlocksMetadataServerTool: BaseServerTool<
// explicitly: unrevealed preview blocks and kill-switched types stay
// out of the agent's metadata (the router wraps this tool in
// withBlockVisibility).
if (isHiddenUnder(overlayVisibility(), blockConfig)) {
if (isHiddenUnder(visibility, blockConfig)) {
logger.debug('Skipping block gated by visibility', { blockId })
continue
}
@@ -0,0 +1,56 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockGetAllBlocks,
mockGetBlock,
mockGetUserPermissionConfig,
mockIsIntegrationDeploymentAvailable,
} = vi.hoisted(() => ({
mockGetAllBlocks: vi.fn(),
mockGetBlock: vi.fn(),
mockGetUserPermissionConfig: vi.fn(),
mockIsIntegrationDeploymentAvailable: vi.fn(() => true),
}))
vi.mock('@/blocks/registry', () => ({
getAllBlocks: mockGetAllBlocks,
getBlock: mockGetBlock,
}))
vi.mock('@/ee/access-control/utils/permission-check', () => ({
getUserPermissionConfig: mockGetUserPermissionConfig,
}))
vi.mock('@/lib/integrations/availability.server', () => ({
isIntegrationDeploymentAvailableForVisibility: mockIsIntegrationDeploymentAvailable,
}))
import { getTriggerBlocksServerTool } from '@/lib/copilot/tools/server/blocks/get-trigger-blocks'
describe('get trigger blocks', () => {
beforeEach(() => {
vi.clearAllMocks()
const blocks = [
{ type: 'start_trigger', category: 'triggers', subBlocks: [] },
{ type: 'slack', category: 'tools', triggerAllowed: true, subBlocks: [] },
{ type: 'notion', category: 'tools', triggerAllowed: true, subBlocks: [] },
]
mockGetAllBlocks.mockReturnValue(blocks)
mockGetBlock.mockImplementation((type: string) => blocks.find((block) => block.type === type))
mockGetUserPermissionConfig.mockResolvedValue({ allowedIntegrations: ['slack'] })
mockIsIntegrationDeploymentAvailable.mockReturnValue(true)
})
it('keeps the start trigger while filtering non-exempt integrations', async () => {
const result = await getTriggerBlocksServerTool.execute(
{},
{ userId: 'user-1', workspaceId: 'workspace-1' }
)
expect(result.triggerBlockIds).toEqual(['slack', 'start_trigger'])
})
})
@@ -2,7 +2,11 @@ import { createLogger } from '@sim/logger'
import { z } from 'zod'
import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool'
import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags'
import { isIntegrationDeploymentAvailableForVisibility } from '@/lib/integrations/availability.server'
import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access'
import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist'
import { getAllBlocks } from '@/blocks/registry'
import { overlayVisibility } from '@/blocks/visibility/context'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
export const GetTriggerBlocksInput = z.object({})
@@ -25,15 +29,23 @@ export const getTriggerBlocksServerTool: BaseServerTool<
context?.userId && context?.workspaceId
? await getUserPermissionConfig(context.userId, context.workspaceId)
: null
const allowedIntegrations =
permissionConfig?.allowedIntegrations ?? getAllowedIntegrationsFromEnv()
const allowedIntegrations = intersectIntegrationAllowlists(
permissionConfig?.allowedIntegrations ?? null,
getAllowedIntegrationsFromEnv()
)
const visibility = overlayVisibility()
const triggerBlockIds: string[] = []
for (const blockConfig of getAllBlocks()) {
const blockType = blockConfig.type
if (blockConfig.hideFromToolbar) continue
if (allowedIntegrations != null && !allowedIntegrations.includes(blockType.toLowerCase()))
if (!isIntegrationDeploymentAvailableForVisibility(blockType, visibility)) continue
if (
allowedIntegrations != null &&
!isBlockTypeAccessControlExempt(blockType) &&
!allowedIntegrations.includes(blockType.toLowerCase())
)
continue
if (blockConfig.category === 'triggers') {
+9 -7
View File
@@ -73,14 +73,16 @@ const logger = createLogger('ServerToolRouter')
const CUSTOM_BLOCK_OVERLAY_TOOLS = new Set(['edit_workflow', 'get_blocks_metadata'])
/**
* DISCOVERY tools that must run inside the viewer's block-visibility context so
* gated (preview / kill-switched) blocks disappear from what the agent can
* list. Deliberately a DIFFERENT set from {@link CUSTOM_BLOCK_OVERLAY_TOOLS}:
* `edit_workflow` is excluded because its registry use is functional
* (find-by-type over clones, never a discovery listing) and gating it would
* only risk leaking display projections into persisted state.
* Discovery tools that consume the viewer's block-visibility context to hide
* gated blocks and credentials. `edit_workflow` establishes a narrower scope
* around operation validation after it resolves the workflow's actual
* workspace.
*/
const VISIBILITY_GATED_TOOLS = new Set(['get_blocks_metadata', 'get_trigger_blocks'])
const VISIBILITY_GATED_TOOLS = new Set([
'get_blocks_metadata',
'get_credentials',
'get_trigger_blocks',
])
const WRITE_ACTIONS: Record<string, string[]> = {
[KnowledgeBase.id]: [
@@ -17,9 +17,22 @@ import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
const SECRET_ACCESS_TOKEN = 'ya29.a0SECRET_GOOGLE_BEARER_TOKEN_DO_NOT_LEAK'
const { getAllOAuthServicesMock, decodeJwtMock } = vi.hoisted(() => ({
const {
getAllOAuthServicesMock,
decodeJwtMock,
isOAuthServiceDeploymentAvailableMock,
createIntegrationCredentialVisibilityMock,
getUserPermissionConfigMock,
getAccessibleOAuthCredentialsMock,
checkWorkspaceAccessMock,
} = vi.hoisted(() => ({
getAllOAuthServicesMock: vi.fn(),
decodeJwtMock: vi.fn(),
isOAuthServiceDeploymentAvailableMock: vi.fn(() => true),
createIntegrationCredentialVisibilityMock: vi.fn(),
getUserPermissionConfigMock: vi.fn(),
getAccessibleOAuthCredentialsMock: vi.fn(),
checkWorkspaceAccessMock: vi.fn(),
}))
const getPersonalAndWorkspaceEnvMock = environmentUtilsMockFns.mockGetPersonalAndWorkspaceEnv
@@ -30,6 +43,30 @@ vi.mock('@/lib/oauth', () => ({
getAllOAuthServices: getAllOAuthServicesMock,
}))
vi.mock('@/lib/integrations/availability.server', () => ({
isOAuthServiceDeploymentAvailable: isOAuthServiceDeploymentAvailableMock,
}))
vi.mock('@/lib/integrations/credential-visibility.server', () => ({
createIntegrationCredentialVisibility: createIntegrationCredentialVisibilityMock,
}))
vi.mock('@/lib/core/config/env-flags', () => ({
getAllowedIntegrationsFromEnv: vi.fn(() => null),
}))
vi.mock('@/ee/access-control/utils/permission-check', () => ({
getUserPermissionConfig: getUserPermissionConfigMock,
}))
vi.mock('@/lib/credentials/environment', () => ({
getAccessibleOAuthCredentials: getAccessibleOAuthCredentialsMock,
}))
vi.mock('@/lib/workspaces/permissions/utils', () => ({
checkWorkspaceAccess: checkWorkspaceAccessMock,
}))
vi.mock('jose', () => ({
decodeJwt: decodeJwtMock,
}))
@@ -72,16 +109,38 @@ describe('getCredentialsServerTool', () => {
getAllOAuthServicesMock.mockReturnValue([
{
serviceId: 'gmail',
providerId: 'google-default',
name: 'Google',
description: 'Google account',
baseProvider: 'google',
authType: 'oauth',
},
{
serviceId: 'slack',
providerId: 'slack',
serviceAccountProviderId: 'slack-custom-bot',
name: 'Slack',
description: 'Slack workspace',
baseProvider: 'slack',
authType: 'oauth',
},
{
serviceId: 'notion',
providerId: 'notion',
serviceAccountProviderId: 'notion-service-account',
name: 'Notion',
description: 'Notion workspace',
baseProvider: 'notion',
authType: 'oauth',
},
{
serviceId: 'claude-platform',
providerId: 'claude-platform',
name: 'Claude Platform',
description: 'Claude managed agents',
baseProvider: 'claude-platform',
authType: 'service_account',
},
])
@@ -92,6 +151,30 @@ describe('getCredentialsServerTool', () => {
})
decodeJwtMock.mockReturnValue({ email: 'brent@cellular.so' })
isOAuthServiceDeploymentAvailableMock.mockReturnValue(true)
getUserPermissionConfigMock.mockResolvedValue(null)
getAccessibleOAuthCredentialsMock.mockResolvedValue([])
checkWorkspaceAccessMock.mockResolvedValue({ canAdmin: false })
createIntegrationCredentialVisibilityMock.mockImplementation(
({ allowedIntegrationTypes, oauthServices }) => {
const isAllowed = (service: { serviceId: string }) =>
allowedIntegrationTypes === null || allowedIntegrationTypes.has(service.serviceId)
const isOAuthServiceVisible = (service: { serviceId: string; providerId: string }) =>
isAllowed(service) && isOAuthServiceDeploymentAvailableMock(service.providerId)
return {
isOAuthServiceVisible,
isCredentialVisible: ({ providerId, type }: { providerId: string; type?: string }) => {
const service = oauthServices.find(
(candidate: { providerId: string; serviceAccountProviderId?: string }) =>
candidate.providerId === providerId ||
candidate.serviceAccountProviderId === providerId
)
if (!service || !isAllowed(service)) return !service
return type === 'service_account' || isOAuthServiceVisible(service)
},
}
}
)
})
it('never returns access tokens for connected OAuth credentials', async () => {
@@ -127,6 +210,67 @@ describe('getCredentialsServerTool', () => {
expect(JSON.stringify(result)).not.toContain('refresh-secret')
})
it('does not advertise OAuth providers unavailable in this deployment', async () => {
isOAuthServiceDeploymentAvailableMock.mockImplementation(
(providerId: string) => providerId !== 'slack'
)
const result = await getCredentialsServerTool.execute({}, { userId: 'user-1' })
expect(
result.oauth.notConnected.services.map(
(service: { providerId: string }) => service.providerId
)
).not.toContain('slack')
})
it('uses context.workspaceId and hides integrations disallowed for the viewer', async () => {
getUserPermissionConfigMock.mockResolvedValue({ allowedIntegrations: ['slack'] })
const result = await getCredentialsServerTool.execute(
{},
{ userId: 'user-1', workspaceId: 'workspace-1' }
)
expect(getUserPermissionConfigMock).toHaveBeenCalledWith('user-1', 'workspace-1')
expect(result.oauth.connected.credentials).toEqual([])
expect(
result.oauth.notConnected.services.map(
(service: { providerId: string }) => service.providerId
)
).toEqual(['slack'])
})
it('does not advertise service-account-only entries as OAuth connections', async () => {
const result = await getCredentialsServerTool.execute({}, { userId: 'user-1' })
expect(
result.oauth.notConnected.services.map(
(service: { providerId: string }) => service.providerId
)
).not.toContain('claude-platform')
})
it('hides shared service-account credentials disallowed for the viewer', async () => {
getUserPermissionConfigMock.mockResolvedValue({ allowedIntegrations: ['slack'] })
getAccessibleOAuthCredentialsMock.mockResolvedValue([
{
id: 'notion-service-account-1',
providerId: 'notion-service-account',
type: 'service_account',
displayName: 'Notion token',
updatedAt: new Date('2026-04-17T02:26:05.546Z'),
},
])
const result = await getCredentialsServerTool.execute(
{},
{ userId: 'user-1', workspaceId: 'workspace-1' }
)
expect(result.oauth.connected.credentials).toEqual([])
})
it('rejects unauthenticated callers without touching the database', async () => {
await expect(getCredentialsServerTool.execute({}, undefined)).rejects.toThrow(
'Authentication required'
@@ -6,10 +6,15 @@ import { eq } from 'drizzle-orm'
import { decodeJwt } from 'jose'
import { createPermissionError, verifyWorkflowAccess } from '@/lib/copilot/auth/permissions'
import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool'
import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags'
import { getAccessibleOAuthCredentials } from '@/lib/credentials/environment'
import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils'
import { createIntegrationCredentialVisibility } from '@/lib/integrations/credential-visibility.server'
import { getAllOAuthServices } from '@/lib/oauth'
import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist'
import { checkWorkspaceAccess, type WorkspaceAccess } from '@/lib/workspaces/permissions/utils'
import { overlayVisibility } from '@/blocks/visibility/context'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
interface GetCredentialsParams {
workflowId?: string
@@ -17,7 +22,7 @@ interface GetCredentialsParams {
export const getCredentialsServerTool: BaseServerTool<GetCredentialsParams, any> = {
name: 'get_credentials',
async execute(params: GetCredentialsParams, context?: { userId: string }): Promise<any> {
async execute(params, context): Promise<any> {
const logger = createLogger('GetCredentialsServerTool')
if (!context?.userId) {
@@ -27,7 +32,7 @@ export const getCredentialsServerTool: BaseServerTool<GetCredentialsParams, any>
const authenticatedUserId = context.userId
let workspaceId: string | undefined
let workspaceId = context.workspaceId
if (params?.workflowId) {
const { hasAccess, workspaceId: wId } = await verifyWorkflowAccess(
@@ -69,8 +74,23 @@ export const getCredentialsServerTool: BaseServerTool<GetCredentialsParams, any>
.limit(1)
const userEmail = userRecord.length > 0 ? userRecord[0]?.email : null
// Get all available OAuth services
const allOAuthServices = getAllOAuthServices()
const permissionConfig = workspaceId ? await getUserPermissionConfig(userId, workspaceId) : null
const configuredAllowedIntegrations = intersectIntegrationAllowlists(
permissionConfig?.allowedIntegrations ?? null,
getAllowedIntegrationsFromEnv()
)
const allowedIntegrationTypes = configuredAllowedIntegrations
? new Set(configuredAllowedIntegrations.map((type) => type.toLowerCase()))
: null
const serviceMetadata = getAllOAuthServices()
const credentialVisibility = createIntegrationCredentialVisibility({
allowedIntegrationTypes,
blockVisibility: overlayVisibility(),
oauthServices: serviceMetadata,
})
const allOAuthServices = serviceMetadata.filter((service) => service.authType === 'oauth')
const visibleOAuthServices = allOAuthServices.filter(credentialVisibility.isOAuthServiceVisible)
// Track connected provider IDs
const connectedProviderIds = new Set<string>()
@@ -86,6 +106,8 @@ export const getCredentialsServerTool: BaseServerTool<GetCredentialsParams, any>
for (const acc of accounts) {
const providerId = acc.providerId
const service = allOAuthServices.find((candidate) => candidate.providerId === providerId)
if (!credentialVisibility.isCredentialVisible({ providerId, type: 'oauth' })) continue
connectedProviderIds.add(providerId)
const [baseProvider, featureType = 'default'] = providerId.split('-')
@@ -105,7 +127,6 @@ export const getCredentialsServerTool: BaseServerTool<GetCredentialsParams, any>
if (!displayName) displayName = `${acc.accountId} (${baseProvider})`
// Find the service name for this provider ID
const service = allOAuthServices.find((s) => s.providerId === providerId)
const serviceName = service?.name ?? providerId
connectedCredentials.push({
@@ -129,14 +150,26 @@ export const getCredentialsServerTool: BaseServerTool<GetCredentialsParams, any>
const seenCredentialIds = new Set(connectedCredentials.map((c) => c.id))
for (const cred of sharedCredentials) {
if (seenCredentialIds.has(cred.id)) continue
if (
!credentialVisibility.isCredentialVisible({
providerId: cred.providerId,
type: cred.type,
})
) {
continue
}
const service = allOAuthServices.find(
(candidate) =>
candidate.providerId === cred.providerId ||
candidate.serviceAccountProviderId === cred.providerId
)
connectedProviderIds.add(cred.providerId)
const [, featureType = 'default'] = cred.providerId.split('-')
connectedCredentials.push({
id: cred.id,
name: cred.displayName,
provider: cred.providerId,
serviceName:
allOAuthServices.find((s) => s.providerId === cred.providerId)?.name ?? cred.providerId,
serviceName: service?.name ?? cred.providerId,
lastUsed: cred.updatedAt.toISOString(),
isDefault: featureType === 'default',
})
@@ -144,7 +177,7 @@ export const getCredentialsServerTool: BaseServerTool<GetCredentialsParams, any>
}
// Build list of not connected services
const notConnectedServices = allOAuthServices
const notConnectedServices = visibleOAuthServices
.filter((service) => !connectedProviderIds.has(service.providerId))
.map((service) => ({
providerId: service.providerId,
@@ -5,9 +5,18 @@ import { describe, expect, it, vi } from 'vitest'
import {
applyTriggerConfigToBlockSubblocks,
createBlockFromParams,
filterDisallowedTools,
normalizeSubblockValue,
} from '@/lib/copilot/tools/server/workflow/edit-workflow/builders'
const { mockIsIntegrationDeploymentAvailable } = vi.hoisted(() => ({
mockIsIntegrationDeploymentAvailable: vi.fn(() => true),
}))
vi.mock('@/lib/integrations/availability.server', () => ({
isIntegrationDeploymentAvailableForVisibility: mockIsIntegrationDeploymentAvailable,
}))
const agentBlockConfig = {
type: 'agent',
name: 'Agent',
@@ -133,6 +142,23 @@ describe('createBlockFromParams', () => {
})
})
describe('filterDisallowedTools', () => {
it('removes unavailable integration tools even without a permission group', () => {
mockIsIntegrationDeploymentAvailable.mockImplementation((type: string) => type !== 'slack')
const skippedItems: Parameters<typeof filterDisallowedTools>[3] = []
const tools = filterDisallowedTools(
[{ type: 'slack' }, { type: 'custom-tool', customToolId: 'custom-1' }],
null,
'agent-1',
skippedItems
)
expect(tools).toEqual([{ type: 'custom-tool', customToolId: 'custom-1' }])
expect(skippedItems[0]?.reason).toContain('unavailable in this deployment')
})
})
describe('normalizeSubblockValue', () => {
it.each(['tagFilters', 'documentTags', 'conditions', 'routes'])(
'serializes %s to a JSON string the subblock component can parse',
@@ -1,6 +1,7 @@
import { createLogger } from '@sim/logger'
import { generateId, isValidUuid } from '@sim/utils/id'
import { sortObjectKeysDeep } from '@sim/utils/object'
import { isIntegrationDeploymentAvailableForVisibility } from '@/lib/integrations/availability.server'
import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
import { getEffectiveBlockOutputs } from '@/lib/workflows/blocks/block-outputs'
import {
@@ -9,8 +10,9 @@ import {
isCanonicalPair,
} from '@/lib/workflows/subblocks/visibility'
import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils'
import { getAllBlocks, getBlock } from '@/blocks/registry'
import { getBlock } from '@/blocks/registry'
import type { BlockConfig } from '@/blocks/types'
import { overlayVisibility } from '@/blocks/visibility/context'
import { TRIGGER_RUNTIME_SUBBLOCK_IDS } from '@/triggers/constants'
import type { EditWorkflowOperation, SkippedItem, ValidationError } from './types'
import { logSkippedItem } from './types'
@@ -31,7 +33,7 @@ export function createBlockFromParams(
permissionConfig?: PermissionGroupConfig | null,
skippedItems?: SkippedItem[]
): any {
const blockConfig = getAllBlocks().find((b) => b.type === params.type)
const blockConfig = getBlock(params.type)
// Validate inputs against block configuration
let validatedInputs: Record<string, any> | undefined
@@ -648,13 +650,30 @@ export function filterDisallowedTools(
blockId: string,
skippedItems: SkippedItem[]
): any[] {
if (!permissionConfig) {
return tools
}
const allowedTools: any[] = []
const deploymentAvailableTools: any[] = []
for (const tool of tools) {
if (
typeof tool?.type === 'string' &&
getBlock(tool.type) &&
!isIntegrationDeploymentAvailableForVisibility(tool.type, overlayVisibility())
) {
logSkippedItem(skippedItems, {
type: 'tool_not_allowed',
operationType: 'add',
blockId,
reason: `Tool block type "${tool.type}" is unavailable in this deployment - tool not added`,
details: { toolType: tool.type },
})
continue
}
deploymentAvailableTools.push(tool)
}
if (!permissionConfig) return deploymentAvailableTools
const allowedTools: any[] = []
for (const tool of deploymentAvailableTools) {
if (tool.type === 'custom-tool' && permissionConfig.disableCustomTools) {
logSkippedItem(skippedItems, {
type: 'tool_not_allowed',
@@ -7,6 +7,7 @@ import {
} from '@sim/platform-authz/workflow'
import { toError } from '@sim/utils/errors'
import { eq } from 'drizzle-orm'
import { getBlockVisibilityForCopilot } from '@/lib/copilot/block-visibility'
import { EditWorkflow } from '@/lib/copilot/generated/tool-catalog-v1'
import {
assertServerToolNotAborted,
@@ -30,6 +31,7 @@ import {
saveWorkflowToNormalizedTables,
} from '@/lib/workflows/persistence/utils'
import { validateWorkflowState } from '@/lib/workflows/sanitization/validation'
import { withBlockVisibility } from '@/blocks/visibility/server-context'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
import { normalizeWorkflowState } from '@/stores/workflows/workflow/validation'
@@ -137,10 +139,10 @@ export const editWorkflowServerTool: BaseServerTool<EditWorkflowParams, unknown>
workflowState = fromDb.workflowState
}
const permissionConfig =
context?.userId && workspaceId
? await getUserPermissionConfig(context.userId, workspaceId)
: null
const [permissionConfig, blockVisibility] = await Promise.all([
workspaceId ? getUserPermissionConfig(context.userId, workspaceId) : null,
getBlockVisibilityForCopilot(context.userId, workspaceId),
])
// Pre-validate credential and apiKey inputs before applying operations
// This filters out invalid credentials and apiKeys for hosted models
@@ -161,7 +163,9 @@ export const editWorkflowServerTool: BaseServerTool<EditWorkflowParams, unknown>
state: modifiedWorkflowState,
validationErrors,
skippedItems,
} = applyOperationsToWorkflowState(workflowState, operationsToApply, permissionConfig)
} = await withBlockVisibility(blockVisibility, async () =>
applyOperationsToWorkflowState(workflowState, operationsToApply, permissionConfig)
)
// Add credential validation errors
validationErrors.push(...credentialErrors)
@@ -56,6 +56,10 @@ vi.mock('@/blocks/registry', () => ({
},
}))
vi.mock('@/lib/integrations/availability.server', () => ({
isIntegrationDeploymentAvailableForVisibility: () => true,
}))
function makeLoopWorkflow() {
return {
blocks: {
@@ -566,7 +566,7 @@ export function handleEditOperation(op: EditWorkflowOperation, ctx: OperationCon
type: 'block_not_allowed',
operationType: 'edit',
blockId: block_id,
reason: `Block type "${params.type}" is not allowed by permission group - type change skipped`,
reason: `Block type "${params.type}" is unavailable in this deployment or blocked by access control - type change skipped`,
details: { requestedType: params.type },
})
} else {
@@ -744,7 +744,7 @@ export function handleAddOperation(op: EditWorkflowOperation, ctx: OperationCont
type: 'block_not_allowed',
operationType: 'add',
blockId: block_id,
reason: `Block type "${params.type}" is not allowed by permission group - block not added`,
reason: `Block type "${params.type}" is unavailable in this deployment or blocked by access control - block not added`,
details: { requestedType: params.type },
})
return
@@ -970,7 +970,7 @@ export function handleInsertIntoSubflowOperation(
type: 'block_not_allowed',
operationType: 'insert_into_subflow',
blockId: block_id,
reason: `Block type "${params.type}" is not allowed by permission group - block not inserted`,
reason: `Block type "${params.type}" is unavailable in this deployment or blocked by access control - block not inserted`,
details: { requestedType: params.type, subflowId },
})
return
@@ -12,6 +12,7 @@ const {
mockGetCustomToolById,
mockGetSkillById,
mockGetHostedModels,
mockIsIntegrationDeploymentAvailable,
} = vi.hoisted(() => ({
mockValidateSelectorIds: vi.fn(),
mockGetModelOptions: vi.fn(() => []),
@@ -19,6 +20,7 @@ const {
mockGetCustomToolById: vi.fn(),
mockGetSkillById: vi.fn(),
mockGetHostedModels: vi.fn(() => [] as string[]),
mockIsIntegrationDeploymentAvailable: vi.fn(() => true),
}))
const conditionBlockConfig = {
@@ -251,6 +253,10 @@ vi.mock('@/providers/utils', () => ({
getHostedModels: mockGetHostedModels,
}))
vi.mock('@/lib/integrations/availability.server', () => ({
isIntegrationDeploymentAvailableForVisibility: mockIsIntegrationDeploymentAvailable,
}))
import {
collectUnresolvedAgentToolReferences,
collectUnresolvedReferences,
@@ -263,6 +269,10 @@ const CTX = { userId: 'user-1', workspaceId: 'workspace-1' }
afterAll(resetEnvFlagsMock)
beforeEach(() => {
mockIsIntegrationDeploymentAvailable.mockReturnValue(true)
})
describe('validateInputsForBlock', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -1229,6 +1239,19 @@ describe('validateInputsForBlock - agent tools (tool-input)', () => {
expect(result.validInputs.tools).toBeDefined()
})
it('rejects an integration tool unavailable in this deployment', () => {
mockIsIntegrationDeploymentAvailable.mockReturnValue(false)
const result = validateInputsForBlock(
'agent',
{ tools: [{ type: 'slack', operation: 'send', usageControl: 'auto' }] },
'agent-1'
)
expect(result.validInputs.tools).toBeUndefined()
expect(result.errors[0]?.error).toContain('unavailable in this deployment')
})
it('rejects an unrecognized tool type', () => {
const result = validateInputsForBlock(
'agent',
@@ -3,6 +3,7 @@ import { toError } from '@sim/utils/errors'
import { omit } from '@sim/utils/object'
import { validateSelectorIds } from '@/lib/copilot/validation/selector-validator'
import { isHosted as isHostedDeployment } from '@/lib/core/config/env-flags'
import { isIntegrationDeploymentAvailableForVisibility } from '@/lib/integrations/availability.server'
import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access'
import type { PermissionGroupConfig } from '@/lib/permission-groups/types'
import { getCustomToolById } from '@/lib/workflows/custom-tools/operations'
@@ -16,6 +17,7 @@ import {
import { getBlock } from '@/blocks/registry'
import type { SubBlockConfig } from '@/blocks/types'
import { getModelOptions } from '@/blocks/utils'
import { overlayVisibility } from '@/blocks/visibility/context'
import { BlockType, EDGE, normalizeName } from '@/executor/constants'
import { isAutoModel, isKnownModelId, suggestModelIdsForUnknownModel } from '@/providers/models'
import { isPiByokOnlyMode } from '@/providers/pi-providers'
@@ -243,6 +245,9 @@ function validateAgentToolEntry(item: any, index: number): string | null {
if (!Array.isArray(block.tools?.access) || block.tools.access.length === 0) {
return `${where} block type "${type}" cannot be attached as an agent tool (it exposes no callable tools)`
}
if (!isIntegrationDeploymentAvailableForVisibility(type, overlayVisibility())) {
return `${where} block type "${type}" is unavailable in this deployment`
}
}
return null
@@ -924,6 +929,7 @@ export function isBlockTypeAllowed(
blockType: string,
permissionConfig: PermissionGroupConfig | null
): boolean {
if (!isIntegrationDeploymentAvailableForVisibility(blockType, overlayVisibility())) return false
if (isBlockTypeAccessControlExempt(blockType)) {
return true
}
@@ -354,6 +354,17 @@ describe('serializeIntegrationSchema — service-account auth', () => {
expect(schema.auth.serviceAccount).toBeUndefined()
})
it('keeps service-account auth while suppressing an unavailable OAuth connection', () => {
const schema = JSON.parse(
serializeIntegrationSchema(oauthTool('notion_read', 'notion'), {
oauthAvailable: false,
})
)
expect(schema.auth.serviceAccount).toEqual({ connectNoun: 'integration secret' })
expect(schema.oauth).toBeUndefined()
})
// The preview-gate behavior (slack custom bot ↔ slack_v2) is covered in
// service-account-gate.test.ts, which mocks getBlock — the block registry is
// globally stubbed here, so slack_v2's real `preview: true` isn't observable
+30 -16
View File
@@ -53,11 +53,13 @@ export type VfsToolAuth =
* per-tool `auth.serviceAccount` field and the `oauth-integrations.json`
* roll-up, so the two never disagree. Returns `undefined` when the service has
* no service-account flow, or its flow is gated by a preview block (a custom
* Slack bot needs slack_v2) GA-only discovery, so the agent never proactively
* offers a preview flow, matching the per-viewer gate the renderer applies.
* Slack bot needs slack_v2) that is not the visible owner being serialized.
* This keeps the default projection GA-only while allowing a revealed preview
* block's own schema to describe the credential flow it enables.
*/
export function describeServiceAccountForOAuthProvider(
oauthProvider: string
oauthProvider: string,
ownerBlockType?: string
): VfsServiceAccountAuth | undefined {
const serviceAccountProviderId = getServiceAccountProviderForProviderId(oauthProvider)
if (!serviceAccountProviderId) return undefined
@@ -69,7 +71,9 @@ export function describeServiceAccountForOAuthProvider(
// static preview check — so once the block GAs and drops `preview`, it is
// no longer hidden and discovery includes it again, matching the renderer.
// Hand-rolling `?.preview ?? true` would keep it omitted forever after GA.
if (!gatingBlock || isHiddenUnder(null, gatingBlock)) return undefined
if (!gatingBlock || (ownerBlockType !== gatingBlockType && isHiddenUnder(null, gatingBlock))) {
return undefined
}
}
return { connectNoun: getServiceAccountConnectNoun(serviceAccountProviderId) }
}
@@ -77,15 +81,23 @@ export function describeServiceAccountForOAuthProvider(
export interface ComponentSerializationOptions {
hosted?: boolean
toolConfigs?: ReadonlyMap<string, ToolConfig>
ownerBlockType?: string
}
/**
* Project runtime tool authentication into a stable, machine-readable VFS contract.
* ToolConfig.hosting remains the source of truth for every hosted-key integration.
*/
export function serializeToolAuth(tool: ToolConfig, hosted = isHosted): VfsToolAuth | undefined {
export function serializeToolAuth(
tool: ToolConfig,
hosted = isHosted,
ownerBlockType?: string
): VfsToolAuth | undefined {
if (tool.oauth) {
const serviceAccount = describeServiceAccountForOAuthProvider(tool.oauth.provider)
const serviceAccount = describeServiceAccountForOAuthProvider(
tool.oauth.provider,
ownerBlockType
)
return {
type: 'oauth',
required: tool.oauth.required,
@@ -600,7 +612,7 @@ export function serializeBlockSchema(
for (const toolId of block.tools.access) {
const tool = options?.toolConfigs?.get(toolId)
if (!tool) continue
const auth = serializeToolAuth(tool, hosted)
const auth = serializeToolAuth(tool, hosted, block.type)
if (auth) toolAuth[toolId] = auth
}
@@ -711,7 +723,6 @@ interface ApiKeyIntegrationTool {
config: ToolConfig
service: string
operation: string
preview?: boolean
}
/**
@@ -719,7 +730,7 @@ interface ApiKeyIntegrationTool {
* ToolConfig.hosting is the only provider registry used to build this index.
*/
export function serializeApiKeyIntegrations(
tools: ApiKeyIntegrationTool[],
tools: readonly ApiKeyIntegrationTool[],
hosted = isHosted
): string {
const services = new Map<
@@ -732,8 +743,8 @@ export function serializeApiKeyIntegrations(
}
>()
for (const { config: tool, service, operation, preview } of tools) {
if (preview || !tool.hosting?.apiKeyParam) continue
for (const { config: tool, service, operation } of tools) {
if (!tool.hosting?.apiKeyParam) continue
const metadata = services.get(service) ?? {
params: [],
@@ -959,10 +970,12 @@ export function serializeSkill(s: {
*/
export function serializeIntegrationSchema(
tool: ToolConfig,
options?: Pick<ComponentSerializationOptions, 'hosted'>
options?: Pick<ComponentSerializationOptions, 'hosted' | 'ownerBlockType'> & {
oauthAvailable?: boolean
}
): string {
const hosted = options?.hosted ?? isHosted
const auth = serializeToolAuth(tool, hosted)
const auth = serializeToolAuth(tool, hosted, options?.ownerBlockType)
const hostedApiKeyParam =
auth?.type === 'api_key' && auth.mode === 'hosted_or_byok' ? auth.param : null
@@ -976,9 +989,10 @@ export function serializeIntegrationSchema(
description: getCopilotToolDescription(tool, { isHosted: hosted }),
version: tool.version,
auth,
oauth: tool.oauth
? { required: tool.oauth.required, provider: tool.oauth.provider }
: undefined,
oauth:
tool.oauth && options?.oauthAvailable !== false
? { required: tool.oauth.required, provider: tool.oauth.provider }
: undefined,
params: tool.params
? {
...Object.fromEntries(
@@ -27,6 +27,14 @@ describe('describeServiceAccountForOAuthProvider — preview gate', () => {
expect(describeServiceAccountForOAuthProvider('slack')).toEqual({ connectNoun: 'custom bot' })
})
it('includes it for the revealed preview block that owns the serialized tool', () => {
mockGetBlock.mockReturnValue({ type: 'slack_v2', preview: true })
expect(describeServiceAccountForOAuthProvider('slack', 'slack_v2')).toEqual({
connectNoun: 'custom bot',
})
})
it('fail-closes (omits) when the gating block is missing entirely', () => {
mockGetBlock.mockReturnValue(undefined)
expect(describeServiceAccountForOAuthProvider('slack')).toBeUndefined()
+228 -90
View File
@@ -26,7 +26,11 @@ import {
} from '@/lib/copilot/chat/workspace-context'
import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1'
import { getExposedIntegrationTools } from '@/lib/copilot/integration-tools'
import {
type ExposedIntegrationTool,
filterExposedIntegrationTools,
getExposedIntegrationTools,
} from '@/lib/copilot/integration-tools'
import { recordVfsMaterialize } from '@/lib/copilot/request/metrics'
import { markSpanForError } from '@/lib/copilot/request/otel'
import { compileDoc, getE2BDocFormat } from '@/lib/copilot/tools/server/files/doc-compile'
@@ -83,7 +87,11 @@ import {
serializeWorkflowMeta,
} from '@/lib/copilot/vfs/serializers'
import type { BlockVisibilityState } from '@/lib/core/config/block-visibility'
import { isDocSandboxEnabled, isHosted } from '@/lib/core/config/env-flags'
import {
getAllowedIntegrationsFromEnv,
isDocSandboxEnabled,
isHosted,
} from '@/lib/core/config/env-flags'
import {
getAccessibleEnvCredentials,
getAccessibleOAuthCredentials,
@@ -91,8 +99,15 @@ import {
import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils'
import { BINARY_DOC_TASKS, MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants'
import { runSandboxTask, SandboxUserCodeError } from '@/lib/execution/sandbox/run-task'
import {
isIntegrationDeploymentAvailableForVisibility,
isOAuthServiceDeploymentAvailable,
} from '@/lib/integrations/availability.server'
import { createIntegrationCredentialVisibility } from '@/lib/integrations/credential-visibility.server'
import { getKnowledgeBases } from '@/lib/knowledge/service'
import { validateMermaidSource } from '@/lib/mermaid/validate'
import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access'
import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist'
import { getWorkspaceShares } from '@/lib/public-shares/share-manager'
import { listTables } from '@/lib/table/service'
import { listWorkspaceFileFolders } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager'
@@ -123,6 +138,7 @@ import { BLOCK_REGISTRY } from '@/blocks/registry-maps'
import type { BlockConfig, BlockIcon } from '@/blocks/types'
import { isHiddenUnder, overlayVisibility } from '@/blocks/visibility/context'
import { CONNECTOR_REGISTRY } from '@/connectors/registry.server'
import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check'
import type { WorkflowState } from '@/stores/workflows/workflow/types'
import type { ToolConfig } from '@/tools/types'
import { TRIGGER_REGISTRY } from '@/triggers/registry'
@@ -149,7 +165,7 @@ let staticComponentFiles: Map<string, string> | null = null
* basename, but integration paths use the version-stripped service name so
* their owners need this lookup for the stamp-time visibility filter.
*/
const integrationPathOwners = new Map<string, Pick<BlockConfig, 'type' | 'preview'>>()
const integrationPathOwners = new Map<string, Array<Pick<BlockConfig, 'type' | 'preview'>>>()
/**
* Owning block(s) for each `components/triggers/{provider}/{id}.json` file,
@@ -168,18 +184,126 @@ const triggerPathOwners = new Map<string, Array<Pick<BlockConfig, 'type' | 'prev
* default with no context and kill-switched types). Non-registry paths
* (loop/parallel, connectors, overviews) are always visible.
*/
function isStaticFileHidden(path: string, vis: BlockVisibilityState | null): boolean {
function isBlockOwnerHidden(
owner: Pick<BlockConfig, 'type' | 'preview'>,
vis: BlockVisibilityState | null,
allowedIntegrationTypes: ReadonlySet<string> | null
): boolean {
const config = BLOCK_REGISTRY[owner.type]
if (config?.hideFromToolbar) return true
if (!isIntegrationDeploymentAvailableForVisibility(owner.type, vis)) return true
if (
allowedIntegrationTypes !== null &&
!isBlockTypeAccessControlExempt(owner.type) &&
!allowedIntegrationTypes.has(owner.type.toLowerCase())
) {
return true
}
return isHiddenUnder(vis, owner)
}
function isStaticFileHidden(
path: string,
vis: BlockVisibilityState | null,
allowedIntegrationTypes: ReadonlySet<string> | null = null
): boolean {
const blockMatch = path.match(/^components\/(?:blocks|triggers\/sim)\/([^/]+)\.json$/)
if (blockMatch) {
const config = BLOCK_REGISTRY[blockMatch[1]!]
return config ? isHiddenUnder(vis, config) : false
return config ? isBlockOwnerHidden(config, vis, allowedIntegrationTypes) : false
}
const triggerOwners = triggerPathOwners.get(path)
if (triggerOwners) {
return triggerOwners.length > 0 && triggerOwners.every((owner) => isHiddenUnder(vis, owner))
return (
triggerOwners.length > 0 &&
triggerOwners.every((owner) => isBlockOwnerHidden(owner, vis, allowedIntegrationTypes))
)
}
const owner = integrationPathOwners.get(path)
return owner ? isHiddenUnder(vis, owner) : false
const owners = integrationPathOwners.get(path)
return owners
? owners.length > 0 &&
owners.every((owner) => isBlockOwnerHidden(owner, vis, allowedIntegrationTypes))
: false
}
function buildIntegrationAggregateFiles(
exposedTools: readonly ExposedIntegrationTool[]
): Map<string, string> {
const oauthServices = new Map<
string,
{
provider: string
operations: string[]
oauthAvailable: boolean
serviceAccount?: VfsServiceAccountAuth
}
>()
for (const { config: tool, service, operation, blockType } of exposedTools) {
if (!tool.oauth?.required) continue
const oauthAvailable = isOAuthServiceDeploymentAvailable(tool.oauth.provider)
const serviceAccount = describeServiceAccountForOAuthProvider(tool.oauth.provider, blockType)
if (!oauthAvailable && !serviceAccount) continue
const existing = oauthServices.get(service)
if (existing) {
existing.operations.push(operation)
existing.oauthAvailable ||= oauthAvailable
existing.serviceAccount ??= serviceAccount
} else {
oauthServices.set(service, {
provider: tool.oauth.provider,
operations: [operation],
oauthAvailable,
serviceAccount,
})
}
}
return new Map([
[
'environment/oauth-integrations.json',
JSON.stringify(Object.fromEntries(oauthServices), null, 2),
],
['environment/api-key-integrations.json', serializeApiKeyIntegrations(exposedTools, isHosted)],
])
}
function buildTriggerOverview(
vis: BlockVisibilityState | null,
allowedIntegrationTypes: ReadonlySet<string> | null
): string {
const builtinTriggers = Object.values(BLOCK_REGISTRY)
.filter(
(block) =>
block.category === 'triggers' &&
!block.preview &&
!isStaticFileHidden(
`components/triggers/sim/${block.type}.json`,
vis,
allowedIntegrationTypes
)
)
.map((block) => ({
id: block.type,
name: block.name,
provider: 'sim',
description: block.description,
}))
const externalTriggers = Object.entries(TRIGGER_REGISTRY)
.filter(
([id, trigger]) =>
!isStaticFileHidden(
`components/triggers/${trigger.provider}/${id}.json`,
vis,
allowedIntegrationTypes
)
)
.map(([id, trigger]) => ({
id,
name: trigger.name,
provider: trigger.provider,
description: trigger.description,
}))
return serializeTriggerOverview(builtinTriggers, externalTriggers)
}
// On-the-fly doc reads (render/extract) download the binary into the Sim process
@@ -214,11 +338,10 @@ function getStaticComponentFiles(): Map<string, string> {
// Raw registry, never the visibility-projected getAllBlocks: this map is a
// process-global shared cache, so it must hold the deterministic ungated
// universe. Preview blocks get schema files here (path-filterable at stamp
// time for revealed viewers) but are EXCLUDED from the shared aggregate
// files (overviews, oauth/api-key summaries) that all viewers receive.
// universe. Preview blocks get schema files here and are filtered per viewer
// at stamp time. Viewer-specific aggregate files are built during materialization.
const allBlocks = Object.values(BLOCK_REGISTRY)
const visibleBlocks = allBlocks.filter((b) => !b.hideFromToolbar)
const visibleBlocks = allBlocks.filter((block) => !block.hideFromToolbar)
const exposedTools = getExposedIntegrationTools()
const toolConfigs = new Map<string, ToolConfig>()
for (const { toolId, config } of exposedTools) {
@@ -235,53 +358,28 @@ function getStaticComponentFiles(): Map<string, string> {
let integrationCount = 0
// `serviceAccount` marks services that also accept a shared service-account
// credential (connect AS AN APPLICATION, not as the user) — the same
// `auth.serviceAccount` shape the per-operation schemas carry, so the agent
// discovers all three auth modes (oauth / api_key / service account) from one
// uniform field instead of a separate file.
const oauthServices = new Map<
string,
{ provider: string; operations: string[]; serviceAccount?: VfsServiceAccountAuth }
>()
// Integration tools come from the shared exposed-tool set (latest version of
// each operation owned by a visible block), the same set used to build the
// deferred callable tools — so discovery and execution can never drift.
for (const exposedTool of exposedTools) {
const { config: tool, service, operation, blockType, preview } = exposedTool
const { config: tool, service, operation } = exposedTool
const path = `components/integrations/${service}/${operation}.json`
files.set(path, serializeIntegrationSchema(tool))
integrationPathOwners.set(path, { type: blockType, preview })
integrationCount++
// Preview-owned tools stay out of the shared oauth/api-key aggregates —
// those files are identical for every viewer.
if (preview) continue
if (tool.oauth?.required) {
const existing = oauthServices.get(service)
if (existing) {
existing.operations.push(operation)
} else {
oauthServices.set(service, {
provider: tool.oauth.provider,
operations: [operation],
serviceAccount: describeServiceAccountForOAuthProvider(tool.oauth.provider),
})
files.set(
path,
serializeIntegrationSchema(tool, {
oauthAvailable: !tool.oauth || isOAuthServiceDeploymentAvailable(tool.oauth.provider),
})
)
const owners = integrationPathOwners.get(path) ?? []
for (const owner of exposedTool.owners) {
if (!owners.some((existing) => existing.type === owner.blockType)) {
owners.push({ type: owner.blockType, preview: owner.preview })
}
}
integrationPathOwners.set(path, owners)
integrationCount++
}
files.set(
'environment/oauth-integrations.json',
JSON.stringify(Object.fromEntries(oauthServices), null, 2)
)
files.set(
'environment/api-key-integrations.json',
serializeApiKeyIntegrations(exposedTools, isHosted)
)
files.set(
'components/blocks/loop.json',
JSON.stringify(
@@ -389,33 +487,7 @@ function getStaticComponentFiles(): Map<string, string> {
externalTriggerCount++
}
files.set(
'components/triggers/triggers.md',
serializeTriggerOverview(
// The overview is a shared file — preview trigger blocks stay out of it
// (their per-type schema file remains discoverable for revealed viewers).
builtinTriggerBlocks
.filter((b) => !b.preview)
.map((b) => ({
id: b.type,
name: b.name,
provider: 'sim',
description: b.description,
})),
// Same for external triggers: a trigger owned solely by preview blocks is
// hidden under the null (no-viewer) state this shared file is built with.
Object.entries(TRIGGER_REGISTRY)
.filter(
([id, t]) => !isStaticFileHidden(`components/triggers/${t.provider}/${id}.json`, null)
)
.map(([id, t]) => ({
id,
name: t.name,
provider: t.provider,
description: t.description,
}))
)
)
files.set('components/triggers/triggers.md', buildTriggerOverview(null, null))
logger.info('Static component files built', {
blocks: visibleBlocks.length,
@@ -659,7 +731,6 @@ export class WorkspaceVFS {
phaseMs[phase] = Date.now() - t0
})
}
await trace
.getTracer('sim-copilot-vfs', '1.0.0')
.startActiveSpan(
@@ -667,6 +738,11 @@ export class WorkspaceVFS {
{ attributes: { [TraceAttr.WorkspaceId]: workspaceId } },
async (span) => {
try {
const blockVisibility = overlayVisibility()
const permissionConfigPromise = timed(
'permissions',
getUserPermissionConfig(userId, workspaceId)
)
const [
wfSummary,
kbSummary,
@@ -679,18 +755,28 @@ export class WorkspaceVFS {
skillsSummary,
wsRow,
members,
permissionConfig,
] = await Promise.all([
timed('workflows', this.materializeWorkflows(workspaceId)),
timed('knowledge_bases', this.materializeKnowledgeBases(workspaceId, userId)),
timed('tables', this.materializeTables(workspaceId)),
timed('files', this.materializeFiles(workspaceId)),
timed('environment', this.materializeEnvironment(workspaceId, userId)),
timed(
'environment',
this.materializeEnvironment(
workspaceId,
userId,
permissionConfigPromise,
blockVisibility
)
),
timed('custom_tools', this.materializeCustomTools(workspaceId, userId)),
timed('custom_blocks', this.materializeCustomBlocks(workspaceId)),
timed('mcp_servers', this.materializeMcpServers(workspaceId)),
timed('skills', this.materializeSkills(workspaceId)),
timed('workspace_row', getWorkspaceWithOwner(workspaceId)),
timed('members', getUsersWithPermissions(workspaceId)),
permissionConfigPromise,
// Writes tasks/ files only — WORKSPACE.md has no Tasks section
// (recent chats reorder every turn and would bust the cached
// prompt prefix), so nothing is destructured from this one.
@@ -719,11 +805,43 @@ export class WorkspaceVFS {
// Per-viewer gating happens HERE, not in the shared builder: files
// owned by blocks hidden for this viewer are skipped at stamp time.
const blockVisibility = overlayVisibility()
const configuredAllowedIntegrations = intersectIntegrationAllowlists(
permissionConfig?.allowedIntegrations ?? null,
getAllowedIntegrationsFromEnv()
)
const allowedIntegrationTypes = configuredAllowedIntegrations
? new Set(configuredAllowedIntegrations.map((type) => type.toLowerCase()))
: null
for (const [path, content] of getStaticComponentFiles()) {
if (isStaticFileHidden(path, blockVisibility)) continue
if (isStaticFileHidden(path, blockVisibility, allowedIntegrationTypes)) continue
this.files.set(path, content)
}
const viewerIntegrationTools = filterExposedIntegrationTools(
getExposedIntegrationTools(),
blockVisibility,
(owner) =>
isIntegrationDeploymentAvailableForVisibility(owner.blockType, blockVisibility) &&
(allowedIntegrationTypes === null ||
allowedIntegrationTypes.has(owner.blockType.toLowerCase()))
)
for (const exposedTool of viewerIntegrationTools) {
const { config: tool, service, operation, blockType } = exposedTool
this.files.set(
`components/integrations/${service}/${operation}.json`,
serializeIntegrationSchema(tool, {
oauthAvailable:
!tool.oauth || isOAuthServiceDeploymentAvailable(tool.oauth.provider),
ownerBlockType: blockType,
})
)
}
for (const [path, content] of buildIntegrationAggregateFiles(viewerIntegrationTools)) {
this.files.set(path, content)
}
this.files.set(
'components/triggers/triggers.md',
buildTriggerOverview(blockVisibility, allowedIntegrationTypes)
)
span.setAttributes({
[TraceAttr.CopilotVfsMaterializeFileCount]: this.files.size,
@@ -2229,19 +2347,39 @@ export class WorkspaceVFS {
*/
private async materializeEnvironment(
workspaceId: string,
userId: string
userId: string,
permissionConfigPromise: ReturnType<typeof getUserPermissionConfig>,
blockVisibility: BlockVisibilityState | null
): Promise<{
oauthIntegrations: WorkspaceMdData['oauthIntegrations']
envVariables: WorkspaceMdData['envVariables']
}> {
try {
const isWorkspaceAdmin = await hasWorkspaceAdminAccess(userId, workspaceId)
const [envCredentials, oauthCredentials, apiKeyRows, envData] = await Promise.all([
getAccessibleEnvCredentials(workspaceId, userId, { isWorkspaceAdmin }),
getAccessibleOAuthCredentials(workspaceId, userId, { isWorkspaceAdmin }),
listApiKeys(workspaceId),
getPersonalAndWorkspaceEnv(userId, workspaceId),
])
const [envCredentials, oauthCredentials, apiKeyRows, envData, permissionConfig] =
await Promise.all([
getAccessibleEnvCredentials(workspaceId, userId, { isWorkspaceAdmin }),
getAccessibleOAuthCredentials(workspaceId, userId, { isWorkspaceAdmin }),
listApiKeys(workspaceId),
getPersonalAndWorkspaceEnv(userId, workspaceId),
permissionConfigPromise,
])
const configuredAllowedIntegrations = intersectIntegrationAllowlists(
permissionConfig?.allowedIntegrations ?? null,
getAllowedIntegrationsFromEnv()
)
const credentialVisibility = createIntegrationCredentialVisibility({
allowedIntegrationTypes: configuredAllowedIntegrations
? new Set(configuredAllowedIntegrations.map((type) => type.toLowerCase()))
: null,
blockVisibility,
})
const visibleOAuthCredentials = oauthCredentials.filter((credential) =>
credentialVisibility.isCredentialVisible({
providerId: credential.providerId,
type: credential.type,
})
)
this.files.set(
'environment/credentials.json',
@@ -2251,7 +2389,7 @@ export class WorkspaceVFS {
scope: c.type === 'env_workspace' ? 'workspace' : 'personal',
createdAt: c.updatedAt,
})),
...oauthCredentials.map((c) => ({
...visibleOAuthCredentials.map((c) => ({
id: c.id,
providerId: c.providerId,
displayName: c.displayName,
@@ -2274,7 +2412,7 @@ export class WorkspaceVFS {
const envKeys = [...new Set(envCredentials.map((c) => c.envKey))]
return {
oauthIntegrations: oauthCredentials.map((c) => ({
oauthIntegrations: visibleOAuthCredentials.map((c) => ({
id: c.id,
providerId: c.providerId,
displayName: c.displayName,
+3 -4
View File
@@ -1,7 +1,7 @@
import { createLogger } from '@sim/logger'
import { taskContext } from '@trigger.dev/core/v3'
import type { AsyncBackendType, JobQueueBackend } from '@/lib/core/async-jobs/types'
import { isTriggerDevEnabled } from '@/lib/core/config/env-flags'
import { getConfiguredAsyncJobsProvider } from '@/lib/core/config/env-capabilities.server'
const logger = createLogger('AsyncJobsConfig')
@@ -19,11 +19,10 @@ let cachedInlineBackend: JobQueueBackend | null = null
* the database backend that nothing's draining.
*/
export function getAsyncBackendType(): AsyncBackendType {
if (isTriggerDevEnabled || taskContext.isInsideTask) {
if (taskContext.isInsideTask) {
return 'trigger-dev'
}
return 'database'
return getConfiguredAsyncJobsProvider()
}
/**
+7 -47
View File
@@ -1,4 +1,5 @@
import { env } from '@/lib/core/config/env'
import { LLM_KEY_POOLS } from '@/lib/core/config/env-capabilities'
/**
* Rotates through available API keys for a provider
@@ -7,56 +8,15 @@ import { env } from '@/lib/core/config/env'
* @throws Error if no API keys are configured for rotation
*/
export function getRotatingApiKey(provider: string): string {
if (
provider !== 'openai' &&
provider !== 'anthropic' &&
provider !== 'gemini' &&
provider !== 'cohere' &&
provider !== 'zai' &&
provider !== 'xai' &&
provider !== 'kimi' &&
provider !== 'fireworks'
) {
if (!(provider in LLM_KEY_POOLS)) {
throw new Error(`No rotation implemented for provider: ${provider}`)
}
const keys = []
if (provider === 'openai') {
if (env.OPENAI_API_KEY_1) keys.push(env.OPENAI_API_KEY_1)
if (env.OPENAI_API_KEY_2) keys.push(env.OPENAI_API_KEY_2)
if (env.OPENAI_API_KEY_3) keys.push(env.OPENAI_API_KEY_3)
} else if (provider === 'anthropic') {
if (env.ANTHROPIC_API_KEY_1) keys.push(env.ANTHROPIC_API_KEY_1)
if (env.ANTHROPIC_API_KEY_2) keys.push(env.ANTHROPIC_API_KEY_2)
if (env.ANTHROPIC_API_KEY_3) keys.push(env.ANTHROPIC_API_KEY_3)
} else if (provider === 'gemini') {
if (env.GEMINI_API_KEY_1) keys.push(env.GEMINI_API_KEY_1)
if (env.GEMINI_API_KEY_2) keys.push(env.GEMINI_API_KEY_2)
if (env.GEMINI_API_KEY_3) keys.push(env.GEMINI_API_KEY_3)
} else if (provider === 'cohere') {
if (env.COHERE_API_KEY_1) keys.push(env.COHERE_API_KEY_1)
if (env.COHERE_API_KEY_2) keys.push(env.COHERE_API_KEY_2)
if (env.COHERE_API_KEY_3) keys.push(env.COHERE_API_KEY_3)
} else if (provider === 'zai') {
if (env.ZAI_API_KEY_1) keys.push(env.ZAI_API_KEY_1)
if (env.ZAI_API_KEY_2) keys.push(env.ZAI_API_KEY_2)
if (env.ZAI_API_KEY_3) keys.push(env.ZAI_API_KEY_3)
} else if (provider === 'xai') {
if (env.XAI_API_KEY_1) keys.push(env.XAI_API_KEY_1)
if (env.XAI_API_KEY_2) keys.push(env.XAI_API_KEY_2)
if (env.XAI_API_KEY_3) keys.push(env.XAI_API_KEY_3)
} else if (provider === 'kimi') {
if (env.KIMI_API_KEY_1) keys.push(env.KIMI_API_KEY_1)
if (env.KIMI_API_KEY_2) keys.push(env.KIMI_API_KEY_2)
if (env.KIMI_API_KEY_3) keys.push(env.KIMI_API_KEY_3)
} else if (provider === 'fireworks') {
if (env.FIREWORKS_API_KEY_1) keys.push(env.FIREWORKS_API_KEY_1)
if (env.FIREWORKS_API_KEY_2) keys.push(env.FIREWORKS_API_KEY_2)
if (env.FIREWORKS_API_KEY_3) keys.push(env.FIREWORKS_API_KEY_3)
// The platform Fireworks key predates the rotation slots and ships as a
// single secret; it stands in as a one-key pool until slots are populated.
if (keys.length === 0 && env.FIREWORKS_API_KEY) keys.push(env.FIREWORKS_API_KEY)
const definition = LLM_KEY_POOLS[provider as keyof typeof LLM_KEY_POOLS]
const keys = definition.keys.map((key) => env[key]).filter((key): key is string => Boolean(key))
if (keys.length === 0 && 'fallbackKey' in definition) {
const fallback = env[definition.fallbackKey]
if (fallback) keys.push(fallback)
}
if (keys.length === 0) {
@@ -0,0 +1,75 @@
/**
* @vitest-environment node
*/
import { resetEnvMock, setEnv } from '@sim/testing'
import { afterAll, beforeEach, describe, expect, expectTypeOf, it } from 'vitest'
import {
inspectConfiguredOAuthClient,
requireConfiguredOAuthClient,
} from '@/lib/core/config/env-capabilities.server'
describe('server environment capabilities', () => {
beforeEach(() => {
setEnv({
SHOPIFY_CLIENT_ID: undefined,
SHOPIFY_CLIENT_SECRET: undefined,
SLACK_CLIENT_ID: undefined,
SLACK_CLIENT_SECRET: undefined,
})
})
afterAll(resetEnvMock)
it('inspects partial OAuth configuration without throwing', () => {
setEnv({ SLACK_CLIENT_ID: 'slack-client' })
expect(inspectConfiguredOAuthClient('slack')).toEqual({
state: 'partial',
missingFields: ['SLACK_CLIENT_SECRET'],
setupCommand: 'bun run setup integration slack',
})
})
it('fails fast when an OAuth client is absent', () => {
expect(() => requireConfiguredOAuthClient('shopify')).toThrow(
'OAuth client shopify is not configured. Run bun run setup integration shopify.'
)
})
it('fails fast when an OAuth client is partially configured', () => {
setEnv({ SLACK_CLIENT_ID: 'slack-client' })
expect(() => requireConfiguredOAuthClient('slack')).toThrow(
'OAuth client slack is partially configured — missing SLACK_CLIENT_SECRET. Run bun run setup integration slack.'
)
})
it('does not expose non-string OAuth values as configured credentials', () => {
setEnv({
SHOPIFY_CLIENT_ID: true,
SHOPIFY_CLIENT_SECRET: 'shopify-secret',
})
expect(inspectConfiguredOAuthClient('shopify')).toMatchObject({
state: 'partial',
missingFields: ['SHOPIFY_CLIENT_ID'],
})
expect(() => requireConfiguredOAuthClient('shopify')).toThrow(/SHOPIFY_CLIENT_ID/)
})
it('returns the validated values with capability-specific field types', () => {
setEnv({
SHOPIFY_CLIENT_ID: 'shopify-client',
SHOPIFY_CLIENT_SECRET: 'shopify-secret',
})
const configured = requireConfiguredOAuthClient('shopify')
expect(configured.values).toEqual({
SHOPIFY_CLIENT_ID: 'shopify-client',
SHOPIFY_CLIENT_SECRET: 'shopify-secret',
})
expectTypeOf(configured.values.SHOPIFY_CLIENT_ID).toEqualTypeOf<string>()
expectTypeOf(configured.values.SHOPIFY_CLIENT_SECRET).toEqualTypeOf<string>()
})
})
@@ -0,0 +1,56 @@
/**
* Binds the pure capability definitions to the application's validated server environment.
*
* @packageDocumentation
*/
import { env } from '@/lib/core/config/env'
import {
ASYNC_JOBS_CAPABILITY,
CACHE_CAPABILITY,
type ConfiguredOAuthClient,
type FallbackCapabilityDefinition,
inspectOAuthClientCapability,
type OAuthClientCapabilityField,
type OAuthClientCapabilityId,
requireCapability,
requireOAuthClientCapability,
SANDBOX_CAPABILITY,
STORAGE_CAPABILITY,
type WireFallbackOptions,
wireFallback,
} from '@/lib/core/config/env-capabilities'
export function getConfiguredStorageProviderId() {
return requireCapability(STORAGE_CAPABILITY, env).providerId
}
export function getConfiguredSandboxProviderId() {
return requireCapability(SANDBOX_CAPABILITY, env).providerId
}
export function getConfiguredAsyncJobsProvider() {
return requireCapability(ASYNC_JOBS_CAPABILITY, env).providerId
}
export function getConfiguredCacheProvider() {
return requireCapability(CACHE_CAPABILITY, env).providerId
}
export function inspectConfiguredOAuthClient(serviceId: string) {
return inspectOAuthClientCapability(serviceId, env)
}
export function requireConfiguredOAuthClient<const TCapabilityId extends OAuthClientCapabilityId>(
serviceId: TCapabilityId
): ConfiguredOAuthClient<OAuthClientCapabilityField<TCapabilityId>>
export function requireConfiguredOAuthClient(serviceId: string): ConfiguredOAuthClient
export function requireConfiguredOAuthClient(serviceId: string): ConfiguredOAuthClient {
return requireOAuthClientCapability(serviceId, env)
}
export function wireServerFallback<
const TDefinition extends FallbackCapabilityDefinition,
TProvider,
>(options: Omit<WireFallbackOptions<TDefinition, TProvider>, 'values'>) {
return wireFallback({ ...options, values: env })
}
@@ -0,0 +1,702 @@
import { describe, expect, it, vi } from 'vitest'
import {
ASYNC_JOBS_CAPABILITY,
CACHE_CAPABILITY,
DEPLOYMENT_CONFIGURATION_KEYS,
defineCapability,
EMAIL_CAPABILITY,
EnvCapabilityConfigurationError,
envField,
inspectCapability,
inspectOAuthClientCapability,
LLM_KEY_POOLS,
OCR_CAPABILITY,
requireCapability,
requireOAuthClientCapability,
resolveOAuthClientCapabilityId,
SANDBOX_CAPABILITY,
STORAGE_CAPABILITY,
validateCapabilityFieldInput,
wireFallback,
} from '@/lib/core/config/env-capabilities'
import integrationsJson from '@/lib/integrations/integrations.json'
import type { Integration } from '@/lib/integrations/types'
import { getServiceConfigByServiceId } from '@/lib/oauth/utils'
const READY_STORAGE_VALUES = {
azure: {
AZURE_CONNECTION_STRING: 'UseDevelopmentStorage=true',
AZURE_STORAGE_CONTAINER_NAME: 'azure-files',
},
s3: {
AWS_REGION: 'us-east-1',
S3_BUCKET_NAME: 's3-files',
},
gcs: {
GCS_BUCKET_NAME: 'gcs-files',
},
} as const
const STORAGE_COMBINATIONS = [
{ azure: false, s3: false, gcs: false, expected: 'local' },
{ azure: false, s3: false, gcs: true, expected: 'gcs' },
{ azure: false, s3: true, gcs: false, expected: 's3' },
{ azure: false, s3: true, gcs: true, expected: 's3' },
{ azure: true, s3: false, gcs: false, expected: 'azure' },
{ azure: true, s3: false, gcs: true, expected: 'azure' },
{ azure: true, s3: true, gcs: false, expected: 'azure' },
{ azure: true, s3: true, gcs: true, expected: 'azure' },
] as const
const READY_EMAIL_VALUES = {
resend: { RESEND_API_KEY: 're_test' },
ses: { AWS_SES_REGION: 'us-east-1' },
smtp: { SMTP_HOST: 'localhost', SMTP_PORT: '1025' },
azure: { AZURE_ACS_CONNECTION_STRING: 'endpoint=https://email.example.com' },
gmail: {
GMAIL_CREDENTIALS_JSON: JSON.stringify({
client_email: 'mailer@example.com',
private_key: 'private-key',
}),
GMAIL_SENDER: 'mailer@example.com',
},
} as const
const EMAIL_PROVIDER_ORDER = ['resend', 'ses', 'smtp', 'azure', 'gmail'] as const
function storageValues({
azure,
s3,
gcs,
}: Pick<(typeof STORAGE_COMBINATIONS)[number], 'azure' | 's3' | 'gcs'>): Record<string, string> {
return Object.assign(
{},
azure ? READY_STORAGE_VALUES.azure : {},
s3 ? READY_STORAGE_VALUES.s3 : {},
gcs ? READY_STORAGE_VALUES.gcs : {}
)
}
describe('env capabilities', () => {
it('fails fast on invalid runtime capability definitions', () => {
const provider = {
id: 'remote',
label: 'Remote',
activation: { mode: 'any-present', keys: ['REMOTE_KEY'] } as const,
requires: envField('REMOTE_KEY'),
}
const base = {
strategy: 'selected',
id: 'sample',
label: 'Sample',
whenUnset: 'default',
} as const
expect(() =>
defineCapability({
...base,
defaultProvider: { id: 'remote', kind: 'provider' },
providers: [provider, provider],
})
).toThrow(/duplicate provider ids/)
expect(() =>
defineCapability({
...base,
defaultProvider: { id: 'missing', kind: 'provider' },
providers: [provider],
})
).toThrow(/default provider missing is not declared/)
})
describe('fallback capabilities', () => {
it('resolves every ready email provider subset in declaration order', () => {
for (let mask = 0; mask < 1 << EMAIL_PROVIDER_ORDER.length; mask += 1) {
const expected = EMAIL_PROVIDER_ORDER.filter((_, index) => (mask & (1 << index)) !== 0)
const values = Object.assign(
{},
...expected.map((providerId) => READY_EMAIL_VALUES[providerId])
)
expect(
inspectCapability(EMAIL_CAPABILITY, values).providerIds,
`provider mask ${mask}`
).toEqual(expected)
}
})
it('reports a broken email provider only when no ready alternative exists', () => {
const partialOnly = inspectCapability(EMAIL_CAPABILITY, { SMTP_HOST: 'localhost' })
expect(partialOnly).toMatchObject({
configured: false,
providerIds: [],
error: expect.any(EnvCapabilityConfigurationError),
})
expect(() => requireCapability(EMAIL_CAPABILITY, { SMTP_HOST: 'localhost' })).toThrow(
/SMTP_PORT/
)
const withAlternative = inspectCapability(EMAIL_CAPABILITY, {
RESEND_API_KEY: 're_test',
SMTP_HOST: 'localhost',
})
expect(withAlternative).toMatchObject({
configured: true,
providerIds: ['resend'],
error: null,
})
expect(withAlternative.providers.find((provider) => provider.id === 'smtp')).toMatchObject({
state: 'partial',
missingFields: ['SMTP_PORT'],
})
const withMalformedAlternative = inspectCapability(EMAIL_CAPABILITY, {
RESEND_API_KEY: 're_test',
GMAIL_CREDENTIALS_JSON: '{}',
GMAIL_SENDER: 'mailer@example.com',
})
expect(withMalformedAlternative).toMatchObject({
configured: true,
providerIds: ['resend'],
error: null,
})
expect(
withMalformedAlternative.providers.find((provider) => provider.id === 'gmail')
).toMatchObject({ state: 'invalid', invalidFields: ['GMAIL_CREDENTIALS_JSON'] })
expect(() =>
requireCapability(EMAIL_CAPABILITY, {
GMAIL_CREDENTIALS_JSON: '{}',
GMAIL_SENDER: 'mailer@example.com',
})
).toThrow(/GMAIL_CREDENTIALS_JSON/)
})
it('preserves anonymous SMTP when only one optional auth field is set', () => {
expect(
requireCapability(EMAIL_CAPABILITY, {
SMTP_HOST: 'localhost',
SMTP_PORT: '1025',
SMTP_USER: 'unused-for-anonymous-relay',
}).providerIds
).toEqual(['smtp'])
})
it('validates setup input with the canonical field rules', () => {
expect(validateCapabilityFieldInput(EMAIL_CAPABILITY, 'SMTP_PORT', '')).toBe('required')
expect(validateCapabilityFieldInput(EMAIL_CAPABILITY, 'SMTP_PORT', '1025')).toBeUndefined()
expect(validateCapabilityFieldInput(EMAIL_CAPABILITY, 'SMTP_PORT', '99999')).toMatch(
/valid port/i
)
expect(
validateCapabilityFieldInput(EMAIL_CAPABILITY, 'GMAIL_CREDENTIALS_JSON', '{}')
).toMatch(/service account/i)
expect(() =>
validateCapabilityFieldInput(EMAIL_CAPABILITY, 'UNKNOWN_EMAIL_FIELD', 'value')
).toThrow(/no validation definition/i)
})
it('executes email providers in order and stops after the first success', async () => {
const resend = { send: vi.fn().mockRejectedValue(new Error('resend down')) }
const ses = { send: vi.fn().mockResolvedValue('sent') }
const smtp = { send: vi.fn().mockResolvedValue('should not run') }
const onFailure = vi.fn()
const fallback = wireFallback({
definition: EMAIL_CAPABILITY,
values: {
RESEND_API_KEY: 're_test',
AWS_SES_REGION: 'us-east-1',
SMTP_HOST: 'localhost',
SMTP_PORT: '1025',
},
factories: {
resend: () => resend,
ses: () => ses,
smtp: () => smtp,
azure: () => null,
gmail: () => null,
},
onFailure,
})
await expect(fallback.execute((provider) => provider.send())).resolves.toBe('sent')
expect(resend.send).toHaveBeenCalledOnce()
expect(ses.send).toHaveBeenCalledOnce()
expect(smtp.send).not.toHaveBeenCalled()
expect(onFailure).toHaveBeenCalledWith('resend', expect.any(Error))
})
it('aggregates failures after every ready email provider fails', async () => {
const resendError = new Error('resend down')
const sesError = new Error('ses down')
const resend = { send: vi.fn().mockRejectedValue(resendError) }
const ses = { send: vi.fn().mockRejectedValue(sesError) }
const onFailure = vi.fn()
const fallback = wireFallback({
definition: EMAIL_CAPABILITY,
values: { RESEND_API_KEY: 're_test', AWS_SES_REGION: 'us-east-1' },
factories: {
resend: () => resend,
ses: () => ses,
smtp: () => null,
azure: () => null,
gmail: () => null,
},
onFailure,
})
const rejection = fallback.execute((provider) => provider.send())
await expect(rejection).rejects.toThrow(/All Email providers failed: resend, ses/)
await expect(rejection).rejects.toMatchObject({ errors: [resendError, sesError] })
expect(onFailure.mock.calls.map(([providerId]) => providerId)).toEqual(['resend', 'ses'])
})
it('fails immediately when a ready provider has no runtime implementation', () => {
expect(() =>
wireFallback({
definition: EMAIL_CAPABILITY,
values: { RESEND_API_KEY: 're_test' },
factories: {
resend: () => null,
ses: () => null,
smtp: () => null,
azure: () => null,
gmail: () => null,
},
})
).toThrow(/factory returned null/)
})
})
describe('storage selection', () => {
it('preserves legacy Azure, S3, GCS, local precedence for unset and blank selectors', () => {
for (const selector of [undefined, '', ' '] as const) {
for (const combination of STORAGE_COMBINATIONS) {
const values = {
...storageValues(combination),
...(selector === undefined ? {} : { STORAGE_PROVIDER: selector }),
}
expect(
inspectCapability(STORAGE_CAPABILITY, values).providerId,
`selector=${JSON.stringify(selector)} combination=${JSON.stringify(combination)}`
).toBe(combination.expected)
}
}
})
it.each([
{
name: 'partial Azure before ready S3',
values: {
AZURE_STORAGE_CONTAINER_NAME: 'azure-files',
...READY_STORAGE_VALUES.s3,
},
expected: 's3',
},
{
name: 'partial Azure before ready GCS',
values: {
AZURE_STORAGE_CONTAINER_NAME: 'azure-files',
...READY_STORAGE_VALUES.gcs,
},
expected: 'gcs',
},
{
name: 'partial S3 before ready GCS',
values: {
S3_BUCKET_NAME: 's3-files',
...READY_STORAGE_VALUES.gcs,
},
expected: 'gcs',
},
{
name: 'partial Azure and S3 before ready GCS',
values: {
AZURE_STORAGE_CONTAINER_NAME: 'azure-files',
S3_BUCKET_NAME: 's3-files',
...READY_STORAGE_VALUES.gcs,
},
expected: 'gcs',
},
])('skips $name', ({ values, expected }) => {
expect(requireCapability(STORAGE_CAPABILITY, values).providerId).toBe(expected)
})
it('accepts both legacy Azure credential forms and GCS application-default credentials', () => {
expect(
requireCapability(STORAGE_CAPABILITY, {
AZURE_ACCOUNT_NAME: 'storage-account',
AZURE_ACCOUNT_KEY: 'storage-key',
AZURE_STORAGE_CONTAINER_NAME: 'azure-files',
}).providerId
).toBe('azure')
expect(
requireCapability(STORAGE_CAPABILITY, { GCS_BUCKET_NAME: 'gcs-files' }).providerId
).toBe('gcs')
})
it('does not activate S3 from general AWS credentials alone', () => {
expect(
requireCapability(STORAGE_CAPABILITY, {
AWS_REGION: 'us-east-1',
AWS_ACCESS_KEY_ID: 'access',
AWS_SECRET_ACCESS_KEY: 'secret',
}).providerId
).toBe('local')
})
it('fails fast when legacy storage configuration is partial and no provider is ready', () => {
expect(() =>
requireCapability(STORAGE_CAPABILITY, {
AZURE_STORAGE_CONTAINER_NAME: 'azure-files',
})
).toThrow(/AZURE_CONNECTION_STRING/)
expect(() =>
requireCapability(STORAGE_CAPABILITY, {
S3_BUCKET_NAME: 's3-files',
})
).toThrow(/AWS_REGION/)
})
it('reports one sufficient Azure credential repair path', () => {
const inspection = inspectCapability(STORAGE_CAPABILITY, {
AZURE_ACCOUNT_NAME: 'storage-account',
AZURE_STORAGE_CONTAINER_NAME: 'azure-files',
})
const azure = inspection.providers.find((provider) => provider.id === 'azure')
expect(azure?.missingFields).toHaveLength(1)
expect(azure?.missingFields[0]).toMatch(/AZURE_(CONNECTION_STRING|ACCOUNT_KEY)/)
})
it('requires paired S3 credentials when either static credential is present', () => {
expect(
requireCapability(STORAGE_CAPABILITY, {
...READY_STORAGE_VALUES.s3,
AWS_ACCESS_KEY_ID: 'access',
AWS_SECRET_ACCESS_KEY: 'secret',
}).providerId
).toBe('s3')
expect(() =>
requireCapability(STORAGE_CAPABILITY, {
STORAGE_PROVIDER: 's3',
...READY_STORAGE_VALUES.s3,
AWS_ACCESS_KEY_ID: 'access',
})
).toThrow(/AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY must be set together/)
})
it('validates only the explicitly selected storage provider', () => {
expect(
requireCapability(STORAGE_CAPABILITY, {
STORAGE_PROVIDER: 'gcs',
GCS_BUCKET_NAME: 'gcs-files',
S3_ENDPOINT: 'ftp://storage.example.com',
}).providerId
).toBe('gcs')
expect(
requireCapability(STORAGE_CAPABILITY, {
STORAGE_PROVIDER: 'local',
AZURE_STORAGE_CONTAINER_NAME: 'partial-azure',
}).providerId
).toBe('local')
})
it('reports missing or invalid fields for an explicitly selected storage provider', () => {
const missing = inspectCapability(STORAGE_CAPABILITY, {
STORAGE_PROVIDER: 's3',
S3_BUCKET_NAME: 's3-files',
})
expect(missing).toMatchObject({
providerId: 's3',
error: expect.any(EnvCapabilityConfigurationError),
})
expect(() =>
requireCapability(STORAGE_CAPABILITY, {
STORAGE_PROVIDER: 's3',
S3_BUCKET_NAME: 's3-files',
})
).toThrow(/AWS_REGION/)
expect(() =>
requireCapability(STORAGE_CAPABILITY, {
STORAGE_PROVIDER: 's3',
...READY_STORAGE_VALUES.s3,
S3_ENDPOINT: 'ftp://storage.example.com',
})
).toThrow(/S3_ENDPOINT/)
expect(inspectCapability(STORAGE_CAPABILITY, { STORAGE_PROVIDER: 'unknown' })).toMatchObject({
providerId: null,
error: expect.any(EnvCapabilityConfigurationError),
})
})
it('fails fast on a complete invalid higher-priority legacy provider', () => {
expect(() =>
requireCapability(STORAGE_CAPABILITY, {
...READY_STORAGE_VALUES.s3,
S3_ENDPOINT: 'ftp://storage.example.com',
...READY_STORAGE_VALUES.gcs,
})
).toThrow(/S3_ENDPOINT/)
})
})
describe('OCR selection', () => {
it('preserves Azure, Mistral, local precedence for every unset-provider combination', () => {
const azureFields = [
['OCR_AZURE_API_KEY', 'azure-key'],
['OCR_AZURE_ENDPOINT', 'https://ocr.example.com'],
['OCR_AZURE_MODEL_NAME', 'mistral-ocr'],
] as const
for (const selector of [undefined, '', ' '] as const) {
for (let mask = 0; mask < 1 << (azureFields.length + 1); mask += 1) {
const values: Record<string, string> = {}
azureFields.forEach(([key, value], index) => {
if ((mask & (1 << index)) !== 0) values[key] = value
})
const hasMistral = (mask & (1 << azureFields.length)) !== 0
if (hasMistral) values.MISTRAL_API_KEY = 'mistral-key'
if (selector !== undefined) values.OCR_PROVIDER = selector
const azureComplete = (mask & 0b111) === 0b111
const azurePartial = (mask & 0b111) !== 0 && !azureComplete
const inspection = inspectCapability(OCR_CAPABILITY, values)
if (azureComplete) {
expect(inspection.providerId, `selector=${JSON.stringify(selector)} mask=${mask}`).toBe(
'azure-mistral'
)
expect(inspection.error).toBeNull()
} else if (hasMistral) {
expect(inspection.providerId, `selector=${JSON.stringify(selector)} mask=${mask}`).toBe(
'mistral'
)
expect(inspection.error).toBeNull()
} else if (azurePartial) {
expect(inspection.error).toBeInstanceOf(EnvCapabilityConfigurationError)
expect(() => requireCapability(OCR_CAPABILITY, values)).toThrow()
} else {
expect(inspection).toMatchObject({ providerId: 'local', error: null })
}
}
}
})
it('validates only the explicitly selected OCR provider', () => {
expect(
requireCapability(OCR_CAPABILITY, {
OCR_PROVIDER: 'local',
MISTRAL_API_KEY: 'mistral-key',
}).providerId
).toBe('local')
expect(() => requireCapability(OCR_CAPABILITY, { OCR_PROVIDER: 'mistral' })).toThrow(
/MISTRAL_API_KEY/
)
expect(() =>
requireCapability(OCR_CAPABILITY, {
OCR_PROVIDER: 'azure-mistral',
OCR_AZURE_API_KEY: 'azure-key',
OCR_AZURE_ENDPOINT: 'ftp://ocr.example.com',
OCR_AZURE_MODEL_NAME: 'mistral-ocr',
})
).toThrow(/OCR_AZURE_ENDPOINT/)
expect(inspectCapability(OCR_CAPABILITY, { OCR_PROVIDER: 'unknown' })).toMatchObject({
providerId: null,
error: expect.any(EnvCapabilityConfigurationError),
})
})
it('preserves legacy Azure validation precedence over Mistral', () => {
expect(() =>
requireCapability(OCR_CAPABILITY, {
OCR_AZURE_API_KEY: 'azure-key',
OCR_AZURE_ENDPOINT: 'ftp://ocr.example.com',
OCR_AZURE_MODEL_NAME: 'mistral-ocr',
MISTRAL_API_KEY: 'mistral-key',
})
).toThrow(/OCR_AZURE_ENDPOINT/)
expect(
requireCapability(OCR_CAPABILITY, {
OCR_AZURE_ENDPOINT: 'ftp://ocr.example.com',
MISTRAL_API_KEY: 'mistral-key',
}).providerId
).toBe('mistral')
})
})
describe('sandbox selection', () => {
it('inspects the legacy E2B default without throwing but requires runtime configuration', () => {
const inspection = inspectCapability(SANDBOX_CAPABILITY, {})
expect(inspection).toMatchObject({
providerId: 'e2b',
error: null,
})
expect(() => requireCapability(SANDBOX_CAPABILITY, {})).toThrow(/E2B_API_KEY/)
})
it('requires E2B credentials when E2B is selected', () => {
expect(
requireCapability(SANDBOX_CAPABILITY, {
E2B_ENABLED: 'true',
E2B_API_KEY: 'e2b-key',
}).providerId
).toBe('e2b')
expect(() =>
requireCapability(SANDBOX_CAPABILITY, {
SANDBOX_PROVIDER: 'e2b',
E2B_ENABLED: 'false',
})
).toThrow(/E2B_API_KEY.*E2B_ENABLED/)
expect(() =>
requireCapability(SANDBOX_CAPABILITY, {
SANDBOX_PROVIDER: 'e2b',
E2B_ENABLED: 'false',
E2B_API_KEY: 'e2b-key',
})
).toThrow(/E2B_ENABLED must be enabled/)
const disabled = inspectCapability(SANDBOX_CAPABILITY, {
SANDBOX_PROVIDER: 'e2b',
E2B_ENABLED: 'false',
})
expect(disabled).toMatchObject({ providerId: 'e2b', error: null })
expect(disabled.providers.find((provider) => provider.id === 'e2b')).toMatchObject({
active: false,
state: 'absent',
})
})
it('requires Daytona credentials and a pinned shell snapshot', () => {
expect(
requireCapability(SANDBOX_CAPABILITY, {
SANDBOX_PROVIDER: 'daytona',
DAYTONA_API_KEY: 'daytona-key',
DAYTONA_SHELL_SNAPSHOT_ID: 'mothership-shell:v1',
}).providerId
).toBe('daytona')
expect(() =>
requireCapability(SANDBOX_CAPABILITY, {
SANDBOX_PROVIDER: 'daytona',
DAYTONA_API_KEY: 'daytona-key',
})
).toThrow(/DAYTONA_SHELL_SNAPSHOT_ID/)
for (const snapshot of ['mothership-shell', 'mothership-shell:latest']) {
expect(() =>
requireCapability(SANDBOX_CAPABILITY, {
SANDBOX_PROVIDER: 'daytona',
DAYTONA_API_KEY: 'daytona-key',
DAYTONA_SHELL_SNAPSHOT_ID: snapshot,
})
).toThrow(/explicit, non-floating name:tag/)
}
})
it('reports an unknown sandbox selector without throwing during inspection', () => {
expect(inspectCapability(SANDBOX_CAPABILITY, { SANDBOX_PROVIDER: 'unknown' })).toMatchObject({
providerId: null,
error: expect.any(EnvCapabilityConfigurationError),
})
expect(() => requireCapability(SANDBOX_CAPABILITY, { SANDBOX_PROVIDER: 'unknown' })).toThrow(
/Unknown SANDBOX_PROVIDER/
)
})
})
describe('jobs and cache selection', () => {
it('uses database jobs unless Trigger.dev is enabled and configured', () => {
expect(requireCapability(ASYNC_JOBS_CAPABILITY, {}).providerId).toBe('database')
expect(
requireCapability(ASYNC_JOBS_CAPABILITY, { TRIGGER_DEV_ENABLED: 'false' }).providerId
).toBe('database')
expect(
requireCapability(ASYNC_JOBS_CAPABILITY, {
TRIGGER_DEV_ENABLED: 'true',
TRIGGER_PROJECT_ID: 'project-id',
TRIGGER_SECRET_KEY: 'secret-key',
}).providerId
).toBe('trigger-dev')
expect(() =>
requireCapability(ASYNC_JOBS_CAPABILITY, {
TRIGGER_DEV_ENABLED: 'true',
TRIGGER_PROJECT_ID: 'project-id',
})
).toThrow(/TRIGGER_SECRET_KEY/)
})
it('uses Redis only when REDIS_URL is present and valid', () => {
expect(requireCapability(CACHE_CAPABILITY, {}).providerId).toBe('database')
expect(
requireCapability(CACHE_CAPABILITY, { REDIS_URL: 'redis://cache.example.com:6379' })
.providerId
).toBe('redis')
expect(() =>
requireCapability(CACHE_CAPABILITY, { REDIS_URL: 'https://cache.example.com' })
).toThrow(/redis:\/\/ or rediss:\/\//)
})
it('requires a TLS server name for rediss IP addresses', () => {
expect(() =>
requireCapability(CACHE_CAPABILITY, { REDIS_URL: 'rediss://10.0.0.1:6379' })
).toThrow(/REDIS_TLS_SERVERNAME/)
expect(
requireCapability(CACHE_CAPABILITY, {
REDIS_URL: 'rediss://10.0.0.1:6379',
REDIS_TLS_SERVERNAME: 'cache.internal',
}).providerId
).toBe('redis')
expect(
requireCapability(CACHE_CAPABILITY, {
REDIS_URL: 'rediss://cache.example.com:6379',
}).providerId
).toBe('redis')
})
})
describe('OAuth and deployment metadata', () => {
it('uses exact OAuth environment names and reports partial pairs', () => {
expect(inspectOAuthClientCapability('zoho-desk', { ZOHO_CLIENT_ID: 'client' })).toMatchObject(
{
state: 'partial',
missingFields: ['ZOHO_CLIENT_SECRET'],
}
)
})
it('fails fast when an OAuth client is partially configured', () => {
expect(() => requireOAuthClientCapability('slack', { SLACK_CLIENT_ID: 'client' })).toThrow(
/SLACK_CLIENT_SECRET/
)
})
it('covers every OAuth integration', () => {
const integrations = integrationsJson.integrations as readonly Integration[]
const uncovered = integrations.flatMap((integration) => {
if (integration.authType !== 'oauth' || !integration.oauthServiceId) return []
if (resolveOAuthClientCapabilityId(integration.oauthServiceId)) return []
return [integration.slug]
})
expect(uncovered).toEqual([])
expect(getServiceConfigByServiceId('trello')?.serviceAccountProviderId).toBe(
'trello-service-account'
)
})
it('tracks setup-owned options as deployment configuration', () => {
expect(DEPLOYMENT_CONFIGURATION_KEYS).toEqual(
expect.arrayContaining([
'DAYTONA_SHELL_SNAPSHOT_ID',
'S3_FORCE_PATH_STYLE',
'STORAGE_PROVIDER',
'OCR_PROVIDER',
])
)
})
it('tracks singular runtime LLM keys as pool fallbacks and deployment configuration', () => {
expect(LLM_KEY_POOLS.openai.fallbackKey).toBe('OPENAI_API_KEY')
expect(LLM_KEY_POOLS.gemini.fallbackKey).toBe('GEMINI_API_KEY')
expect(LLM_KEY_POOLS.cohere.fallbackKey).toBe('COHERE_API_KEY')
expect(DEPLOYMENT_CONFIGURATION_KEYS).toEqual(
expect.arrayContaining(['OPENAI_API_KEY', 'GEMINI_API_KEY', 'COHERE_API_KEY'])
)
})
})
})
File diff suppressed because it is too large Load Diff
+19 -10
View File
@@ -1,12 +1,15 @@
/**
* Environment utility functions for consistent environment detection across the application
* Loaded by `next.config.ts` before the `@/` alias is available, so
* config-boundary dependencies in this module must use relative imports.
*/
import {
ENTERPRISE_FEATURE_LEGACY_DEFAULTS,
type EnterpriseFeature,
resolveEnterpriseEntitlement,
} from './enterprise-entitlements'
import { env, envBoolean, getEnv, isFalsy, isTruthy } from './env'
import { hasEnvCapabilityValue, inspectCapability, SANDBOX_CAPABILITY } from './env-capabilities'
/**
* Is the application running in production mode
@@ -390,15 +393,14 @@ export const isForkingEnabled = enterpriseFeatureEnabled(
* Availability below is derived from THIS provider's credentials, so a
* Daytona-only deployment (E2B unset) still enables remote execution.
*/
const sandboxProvider = (env.SANDBOX_PROVIDER || 'e2b').toLowerCase()
const sandboxProvider = inspectCapability(SANDBOX_CAPABILITY, env).providerId
/**
* Whether remote code/shell execution is available with the selected provider.
*
* E2B keeps its explicit `E2B_ENABLED` switch; Daytona is available once its API
* key is set (the shell snapshot is verified at create time, failing closed).
* Mirrors the E2B gate exactly when the provider is E2B, so existing behavior is
* unchanged.
* key is set. Strict credential and snapshot validation runs when the selected
* remote backend is used, so unrelated app paths preserve legacy enablement.
*
* The browser twin is `NEXT_PUBLIC_SANDBOX_ENABLED`, read by the Function
* block's `showWhenEnvSet` gates. It exists because `NEXT_PUBLIC_E2B_ENABLED`
@@ -408,7 +410,11 @@ const sandboxProvider = (env.SANDBOX_PROVIDER || 'e2b').toLowerCase()
* `bun run setup --doctor` flags the mismatch.
*/
export const isRemoteSandboxEnabled =
sandboxProvider === 'daytona' ? Boolean(env.DAYTONA_API_KEY) : isTruthy(env.E2B_ENABLED)
sandboxProvider === 'daytona'
? hasEnvCapabilityValue(env, 'DAYTONA_API_KEY')
: sandboxProvider === 'e2b'
? isTruthy(env.E2B_ENABLED)
: false
/**
* Whether the document-generation sandbox is available with the selected
@@ -424,10 +430,13 @@ export const isRemoteSandboxEnabled =
*/
export const isDocSandboxEnabled =
sandboxProvider === 'daytona'
? Boolean(env.DAYTONA_API_KEY) && Boolean(env.DAYTONA_DOC_SNAPSHOT_ID)
: isTruthy(env.E2B_ENABLED) &&
Boolean(env.E2B_API_KEY) &&
Boolean(env.MOTHERSHIP_E2B_DOC_TEMPLATE_ID)
? hasEnvCapabilityValue(env, 'DAYTONA_API_KEY') &&
hasEnvCapabilityValue(env, 'DAYTONA_DOC_SNAPSHOT_ID')
: sandboxProvider === 'e2b'
? isTruthy(env.E2B_ENABLED) &&
hasEnvCapabilityValue(env, 'E2B_API_KEY') &&
hasEnvCapabilityValue(env, 'MOTHERSHIP_E2B_DOC_TEMPLATE_ID')
: false
/**
* Whether Ollama is configured (OLLAMA_URL is set).
+4
View File
@@ -70,6 +70,10 @@ export const env = createEnv({
// Database & Storage
REDIS_URL: z.string().url().optional(), // Redis connection string for caching/sessions
REDIS_TLS_SERVERNAME: z.string().min(1).optional(), // TLS SNI override; required when REDIS_URL targets an IP over rediss:// (e.g. trigger.dev PrivateLink VPCE IP) so cert hostname verification matches the ElastiCache cert's CN
/** Explicit file-storage backend; unset preserves Azure → S3 → GCS → local precedence. */
STORAGE_PROVIDER: z.enum(['local', 's3', 'azure', 'gcs']).optional(),
/** Explicit PDF OCR backend; legacy installs infer it from configured credentials. */
OCR_PROVIDER: z.enum(['local', 'mistral', 'azure-mistral']).optional(),
// Payment & Billing
STRIPE_SECRET_KEY: z.string().min(1).optional(), // Stripe secret key for payment processing
+40 -11
View File
@@ -1,7 +1,11 @@
import { createEnvMock, createMockRedis } from '@sim/testing'
import { createMockRedis } from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { MockRedisConstructor } = vi.hoisted(() => ({
const { mockEnv, MockRedisConstructor } = vi.hoisted(() => ({
mockEnv: {
REDIS_URL: 'redis://localhost:6379' as string | undefined,
REDIS_TLS_SERVERNAME: undefined as string | undefined,
},
MockRedisConstructor: vi.fn(),
}))
@@ -15,7 +19,7 @@ MockRedisConstructor.mockImplementation(
)
vi.unmock('@/lib/core/config/redis')
vi.mock('@/lib/core/config/env', () => createEnvMock({ REDIS_URL: 'redis://localhost:6379' }))
vi.mock('@/lib/core/config/env', () => ({ env: mockEnv }))
vi.mock('ioredis', () => ({
default: MockRedisConstructor,
}))
@@ -33,6 +37,8 @@ describe('redis config', () => {
vi.clearAllMocks()
vi.useFakeTimers()
resetForTesting()
mockEnv.REDIS_URL = 'redis://localhost:6379'
mockEnv.REDIS_TLS_SERVERNAME = undefined
MockRedisConstructor.mockImplementation(
class {
constructor() {
@@ -193,17 +199,40 @@ describe('redis config', () => {
expect(extended).toBe(false)
})
it('returns true as a no-op when Redis is unavailable', async () => {
vi.resetModules()
vi.doMock('@/lib/core/config/env', () =>
createEnvMock({ REDIS_URL: undefined as unknown as string })
)
const { extendLock: extendLockNoRedis } = await import('@/lib/core/config/redis')
it('returns true as a no-op when the cache capability selects the database', async () => {
mockEnv.REDIS_URL = undefined
const extended = await extendLockNoRedis(lockKey, value, ttlSeconds)
const extended = await extendLock(lockKey, value, ttlSeconds)
expect(extended).toBe(true)
vi.doUnmock('@/lib/core/config/env')
})
})
describe('capability validation', () => {
it('rejects a non-Redis URL before constructing a client', () => {
mockEnv.REDIS_URL = 'https://cache.example.com'
expect(() => getRedisClient()).toThrow(/valid redis:\/\/ or rediss:\/\/ URL/)
expect(MockRedisConstructor).not.toHaveBeenCalled()
})
it('requires TLS servername for a rediss IP before constructing a client', () => {
mockEnv.REDIS_URL = 'rediss://10.0.0.1:6379'
expect(() => getRedisClient()).toThrow(/REDIS_TLS_SERVERNAME is required/)
expect(MockRedisConstructor).not.toHaveBeenCalled()
})
it('passes the configured TLS servername to Redis', () => {
mockEnv.REDIS_URL = 'rediss://10.0.0.1:6379'
mockEnv.REDIS_TLS_SERVERNAME = 'cache.example.com'
getRedisClient()
expect(MockRedisConstructor).toHaveBeenCalledWith(
mockEnv.REDIS_URL,
expect.objectContaining({ tls: { servername: 'cache.example.com' } })
)
})
})
+12 -2
View File
@@ -3,11 +3,10 @@ import { toError } from '@sim/utils/errors'
import { randomFloat } from '@sim/utils/random'
import Redis, { type RedisOptions } from 'ioredis'
import { env } from '@/lib/core/config/env'
import { getConfiguredCacheProvider } from '@/lib/core/config/env-capabilities.server'
const logger = createLogger('Redis')
const redisUrl = env.REDIS_URL
/**
* When REDIS_URL targets a bare IP over `rediss://` (e.g. trigger.dev's
* PrivateLink VPCE IP), default TLS hostname verification fails the cert
@@ -77,6 +76,16 @@ const state = g._redisState
const PING_INTERVAL_MS = 15_000
const MAX_PING_FAILURES = 2
export function getConfiguredRedisUrl(): string | null {
if (getConfiguredCacheProvider() === 'database') return null
const redisUrl = env.REDIS_URL
if (!redisUrl) {
throw new Error('Cache capability selected Redis but REDIS_URL is missing')
}
return redisUrl
}
/**
* Register a callback that fires when the PING health check forces a reconnect.
* Useful for resetting cached adapters that hold a stale Redis reference.
@@ -140,6 +149,7 @@ function startPingHealthCheck(redis: Redis): void {
*/
export function getRedisClient(): Redis | null {
if (typeof window !== 'undefined') return null
const redisUrl = getConfiguredRedisUrl()
if (!redisUrl) return null
if (state.client) return state.client
+1 -2
View File
@@ -81,8 +81,7 @@ const POLL_INTERVAL_MS = 1000
*
* Storage is determined once based on configuration:
* - If `forceStorage` is set that backend unconditionally
* - Else if `REDIS_URL` is set Redis
* - Else PostgreSQL
* - Else use the provider selected by the cache capability
*/
export class IdempotencyService {
private config: Required<Omit<IdempotencyConfig, 'forceStorage'>>
+6 -5
View File
@@ -1,4 +1,5 @@
import { createLogger } from '@sim/logger'
import { getConfiguredCacheProvider } from '@/lib/core/config/env-capabilities.server'
import { getRedisClient } from '@/lib/core/config/redis'
const logger = createLogger('Storage')
@@ -11,8 +12,8 @@ let cachedStorageMethod: StorageMethod | null = null
* Determine the storage method once based on configuration.
* This decision is made at first call and cached for the lifetime of the process.
*
* - If REDIS_URL is configured and client initializes 'redis'
* - If REDIS_URL is not configured 'database'
* - If the cache capability selects Redis and the client initializes 'redis'
* - If the cache capability selects the built-in provider 'database'
*
* Transient failures do NOT change the storage method.
* If Redis is configured but fails, operations will fail (not fallback to DB).
@@ -22,9 +23,9 @@ export function getStorageMethod(): StorageMethod {
return cachedStorageMethod
}
const redis = getRedisClient()
if (redis) {
if (getConfiguredCacheProvider() === 'redis') {
const redis = getRedisClient()
if (!redis) throw new Error('REDIS_URL is configured but the Redis client is unavailable')
cachedStorageMethod = 'redis'
logger.info('Storage method: Redis')
} else {
+4 -10
View File
@@ -9,8 +9,7 @@ import { EventEmitter } from 'events'
import { createLogger } from '@sim/logger'
import { noop } from '@sim/utils/helpers'
import Redis, { type RedisOptions } from 'ioredis'
import { env } from '@/lib/core/config/env'
import { getRedisConnectionDefaults } from '@/lib/core/config/redis'
import { getConfiguredRedisUrl, getRedisConnectionDefaults } from '@/lib/core/config/redis'
const logger = createLogger('PubSub')
@@ -138,7 +137,7 @@ class LocalPubSubChannel<T> implements PubSubChannel<T> {
}
export function createPubSubChannel<T>(config: PubSubChannelConfig): PubSubChannel<T> {
const redisUrl = env.REDIS_URL
const redisUrl = getConfiguredRedisUrl()
if (!redisUrl) return new LocalPubSubChannel<T>(config)
// Resolve config-derived defaults outside the try so a missing
@@ -146,11 +145,6 @@ export function createPubSubChannel<T>(config: PubSubChannelConfig): PubSubChann
// to the in-process EventEmitter — that would break cross-replica pub/sub.
const connectionDefaults = getRedisConnectionDefaults(redisUrl)
try {
logger.info(`${config.label}: Using Redis`)
return new RedisPubSubChannel<T>(redisUrl, connectionDefaults, config)
} catch (err) {
logger.error(`Failed to create Redis ${config.label}, falling back to local:`, err)
return new LocalPubSubChannel<T>(config)
}
logger.info(`${config.label}: Using Redis`)
return new RedisPubSubChannel<T>(redisUrl, connectionDefaults, config)
}
+6 -2
View File
@@ -1,7 +1,7 @@
/**
* @vitest-environment node
*/
import { redisConfigMockFns, resetRedisConfigMock } from '@sim/testing'
import { redisConfigMockFns, resetEnvMock, resetRedisConfigMock, setEnv } from '@sim/testing'
import { sleep } from '@sim/utils/helpers'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ExecutionEventEntry } from '@/lib/execution/event-buffer'
@@ -26,7 +26,10 @@ const { mockRedis, persistedEntries } = vi.hoisted(() => {
const mockGetRedisClient = redisConfigMockFns.mockGetRedisClient
afterAll(resetRedisConfigMock)
afterAll(() => {
resetEnvMock()
resetRedisConfigMock()
})
import {
createExecutionEventWriter,
@@ -77,6 +80,7 @@ function countOccurrences(haystack: string, needle: string): number {
describe('execution event buffer', () => {
beforeEach(() => {
vi.clearAllMocks()
setEnv({ REDIS_URL: 'redis://localhost:6379' })
persistedEntries.length = 0
mockGetRedisClient.mockReturnValue(mockRedis)
mockRedis.get.mockResolvedValue(null)
+2 -2
View File
@@ -1,7 +1,7 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { randomInt } from '@sim/utils/random'
import { env } from '@/lib/core/config/env'
import { getConfiguredCacheProvider } from '@/lib/core/config/env-capabilities.server'
import { getRedisClient } from '@/lib/core/config/redis'
import { LARGE_VALUE_THRESHOLD_BYTES } from '@/lib/execution/payloads/large-value-ref'
import { compactExecutionPayload } from '@/lib/execution/payloads/serializer'
@@ -364,7 +364,7 @@ async function compactEventForBuffer(
const memoryExecutionStreams = new Map<string, MemoryExecutionStream>()
function canUseMemoryEventBuffer(): boolean {
return typeof window === 'undefined' && !env.REDIS_URL
return typeof window === 'undefined' && getConfiguredCacheProvider() === 'database'
}
function pruneExpiredMemoryStreams(now = Date.now()): void {
+2 -2
View File
@@ -7,6 +7,7 @@ import { getErrorMessage, toError } from '@sim/utils/errors'
import { filterUndefined } from '@sim/utils/object'
import { randomFloat } from '@sim/utils/random'
import { env } from '@/lib/core/config/env'
import { getConfiguredCacheProvider } from '@/lib/core/config/env-capabilities.server'
import { getRedisClient } from '@/lib/core/config/redis'
import {
type SecureFetchOptions,
@@ -350,8 +351,7 @@ async function tryAcquireDistributedLease(
leaseId: string,
timeoutMs: number
): Promise<LeaseAcquireResult> {
// Redis not configured: explicit local-mode fallback is allowed.
if (!env.REDIS_URL) return 'acquired'
if (getConfiguredCacheProvider() === 'database') return 'acquired'
const redis = getRedisClient()
if (!redis) {
@@ -37,6 +37,7 @@ const {
mockEnv: {
SANDBOX_PROVIDER: 'e2b' as string | undefined,
PI_SANDBOX_LIFETIME_MS: undefined as string | undefined,
E2B_ENABLED: 'true',
E2B_API_KEY: 'test-key',
MOTHERSHIP_E2B_TEMPLATE_ID: 'mothership-shell',
MOTHERSHIP_E2B_DOC_TEMPLATE_ID: 'mothership-docs',
@@ -3,99 +3,108 @@
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
/**
* The resolver reads configuration at import, so each case re-imports the module
* with its own mocked environment rather than mutating shared state.
*/
async function resolveWith(options: {
provider?: string
lifetimeMs?: string
}): Promise<{ lifetime: number | undefined; min: number; max: number }> {
vi.resetModules()
vi.doMock('@/lib/core/config/env', () => ({
env: {
PI_SANDBOX_LIFETIME_MS: options.lifetimeMs,
SANDBOX_PROVIDER: options.provider,
},
}))
const { mockEnv } = vi.hoisted(() => ({
mockEnv: {
PI_SANDBOX_LIFETIME_MS: undefined as string | undefined,
SANDBOX_PROVIDER: undefined as string | undefined,
},
}))
const mod = await import('@/lib/execution/remote-sandbox/pi-lifetime')
vi.mock('@/lib/core/config/env', () => ({ env: mockEnv }))
import { createTimeoutAbortController } from '@/lib/core/execution-limits'
import {
PI_SANDBOX_MAX_LIFETIME_MS,
PI_SANDBOX_MIN_LIFETIME_MS,
resolvePiRunLifetimeMs,
resolvePiSandboxLifetimeMs,
} from '@/lib/execution/remote-sandbox/pi-lifetime'
function resolveWith(options: { provider?: string; lifetimeMs?: string }): {
lifetime: number | undefined
min: number
max: number
} {
mockEnv.PI_SANDBOX_LIFETIME_MS = options.lifetimeMs
mockEnv.SANDBOX_PROVIDER = options.provider
return {
lifetime: mod.resolvePiSandboxLifetimeMs(),
min: mod.PI_SANDBOX_MIN_LIFETIME_MS,
max: mod.PI_SANDBOX_MAX_LIFETIME_MS,
lifetime: resolvePiSandboxLifetimeMs(),
min: PI_SANDBOX_MIN_LIFETIME_MS,
max: PI_SANDBOX_MAX_LIFETIME_MS,
}
}
beforeEach(() => {
vi.resetModules()
mockEnv.PI_SANDBOX_LIFETIME_MS = undefined
mockEnv.SANDBOX_PROVIDER = undefined
})
describe('resolvePiSandboxLifetimeMs', () => {
it('defaults to the sub-hour cap on E2B', async () => {
const { lifetime, max } = await resolveWith({})
it('defaults to the sub-hour cap on E2B', () => {
const { lifetime, max } = resolveWith({})
expect(lifetime).toBe(max)
})
it('matches provider selection by treating an empty provider as E2B', async () => {
const { lifetime, max } = await resolveWith({ provider: '' })
it('matches provider selection by treating an empty provider as E2B', () => {
const { lifetime, max } = resolveWith({ provider: '' })
expect(lifetime).toBe(max)
})
it('has no lifetime to report when the provider stops on inactivity', async () => {
it('has no lifetime to report when the provider stops on inactivity', () => {
// Daytona has no absolute lifetime, so reporting E2B's would cut the agent
// turn to fit a ceiling that does not apply — the regression this prevents.
const { lifetime } = await resolveWith({ provider: 'daytona' })
const { lifetime } = resolveWith({ provider: 'daytona' })
expect(lifetime).toBeUndefined()
})
it('ignores a configured lifetime entirely on that provider', async () => {
const { lifetime } = await resolveWith({ provider: 'daytona', lifetimeMs: '600000' })
it('ignores a configured lifetime entirely on that provider', () => {
const { lifetime } = resolveWith({ provider: 'daytona', lifetimeMs: '600000' })
expect(lifetime).toBeUndefined()
})
it('lets a configured value lower the lifetime', async () => {
const { lifetime, min, max } = await resolveWith({ lifetimeMs: String(45 * 60 * 1000) })
it('uses tolerant capability inspection for an unknown provider', () => {
const { lifetime } = resolveWith({ provider: 'modal' })
expect(lifetime).toBeUndefined()
})
it('lets a configured value lower the lifetime', () => {
const { lifetime, min, max } = resolveWith({ lifetimeMs: String(45 * 60 * 1000) })
expect(lifetime).toBe(45 * 60 * 1000)
expect(lifetime!).toBeGreaterThan(min)
expect(lifetime!).toBeLessThan(max)
})
it('refuses to be raised above the cap', async () => {
it('refuses to be raised above the cap', () => {
// A Hobby key rejects a create above one hour, so an over-large override
// would otherwise fail every Pi run rather than lengthening one.
const { lifetime, max } = await resolveWith({ lifetimeMs: String(6 * 60 * 60 * 1000) })
const { lifetime, max } = resolveWith({ lifetimeMs: String(6 * 60 * 60 * 1000) })
expect(lifetime).toBe(max)
})
it('raises a lifetime too short for a run to finish in', async () => {
it('raises a lifetime too short for a run to finish in', () => {
// Ten minutes is consumed by the clone reserve alone, leaving the turn and
// the push to race a sandbox that may already be reaped.
const { lifetime, min } = await resolveWith({ lifetimeMs: String(10 * 60 * 1000) })
const { lifetime, min } = resolveWith({ lifetimeMs: String(10 * 60 * 1000) })
expect(lifetime).toBe(min)
})
it.each(['', 'soon', '0', '-1'])('falls back to the cap for %o', async (value) => {
const { lifetime, max } = await resolveWith({ lifetimeMs: value })
it.each(['', 'soon', '0', '-1'])('falls back to the cap for %o', (value) => {
const { lifetime, max } = resolveWith({ lifetimeMs: value })
expect(lifetime).toBe(max)
})
})
describe('resolvePiRunLifetimeMs', () => {
it('keeps the provider ceiling when the execution is untimed', async () => {
const { createTimeoutAbortController } = await import('@/lib/core/execution-limits')
const { resolvePiRunLifetimeMs, PI_SANDBOX_MAX_LIFETIME_MS } = await import(
'@/lib/execution/remote-sandbox/pi-lifetime'
)
it('keeps the provider ceiling when the execution is untimed', () => {
// No timeout means no deadline was recorded, so there is nothing to narrow
// to — the ceiling is the only bound available.
const untimed = createTimeoutAbortController()
@@ -104,12 +113,7 @@ describe('resolvePiRunLifetimeMs', () => {
expect(resolvePiRunLifetimeMs()).toBe(PI_SANDBOX_MAX_LIFETIME_MS)
})
it('narrows to the deadline of a run shorter than the ceiling', async () => {
const { createTimeoutAbortController } = await import('@/lib/core/execution-limits')
const { resolvePiRunLifetimeMs, PI_SANDBOX_MAX_LIFETIME_MS } = await import(
'@/lib/execution/remote-sandbox/pi-lifetime'
)
it('narrows to the deadline of a run shorter than the ceiling', () => {
// A free-plan sync run gets five minutes. Handing its sandbox the sub-hour
// ceiling is what left an orphan billing for an hour after a five-minute run.
const timeout = createTimeoutAbortController(5 * 60 * 1000)
@@ -121,12 +125,7 @@ describe('resolvePiRunLifetimeMs', () => {
timeout.cleanup()
})
it('keeps the ceiling when the run outlives it', async () => {
const { createTimeoutAbortController } = await import('@/lib/core/execution-limits')
const { resolvePiRunLifetimeMs, PI_SANDBOX_MAX_LIFETIME_MS } = await import(
'@/lib/execution/remote-sandbox/pi-lifetime'
)
it('keeps the ceiling when the run outlives it', () => {
// The deadline must be strictly past the ceiling for the ceiling to win.
// Passing exactly `PI_SANDBOX_MAX_LIFETIME_MS` made this a coin flip: the
// remaining budget is `deadline - Date.now()`, so it decays below the ceiling
@@ -140,27 +139,17 @@ describe('resolvePiRunLifetimeMs', () => {
timeout.cleanup()
})
it('keeps the ceiling for a signal that carries no deadline', async () => {
const { resolvePiRunLifetimeMs, PI_SANDBOX_MAX_LIFETIME_MS } = await import(
'@/lib/execution/remote-sandbox/pi-lifetime'
)
it('keeps the ceiling for a signal that carries no deadline', () => {
// A derived or foreign signal reports `undefined` remaining, which means
// "unknown", not "expired" — narrowing to zero there would kill every run.
expect(resolvePiRunLifetimeMs(new AbortController().signal)).toBe(PI_SANDBOX_MAX_LIFETIME_MS)
})
it('has no lifetime to narrow on a provider without one', async () => {
vi.resetModules()
vi.doMock('@/lib/core/config/env', () => ({
env: { SANDBOX_PROVIDER: 'daytona' },
}))
const { createTimeoutAbortController } = await import('@/lib/core/execution-limits')
const { resolvePiRunLifetimeMs } = await import('@/lib/execution/remote-sandbox/pi-lifetime')
it('has no lifetime to narrow on a provider without one', () => {
// Daytona stops on inactivity, so imposing the run's deadline as an absolute
// lifetime would cut a turn to fit a limit that does not apply to it.
const timeout = createTimeoutAbortController(5 * 60 * 1000)
mockEnv.SANDBOX_PROVIDER = 'daytona'
expect(resolvePiRunLifetimeMs(timeout.signal)).toBeUndefined()
timeout.cleanup()
@@ -6,17 +6,18 @@
import { createLogger } from '@sim/logger'
import { env } from '@/lib/core/config/env'
import { inspectCapability, SANDBOX_CAPABILITY } from '@/lib/core/config/env-capabilities'
import { getMaxExecutionTimeout, getRemainingExecutionMs } from '@/lib/core/execution-limits'
const logger = createLogger('PiSandboxLifetime')
/**
* Read from `env` rather than the `env-flags` gate, and normalized the same way
* `remote-sandbox/index.ts` normalizes it, so this module keeps the independence
* its header describes: no provider adapters, no barrel, no config gate.
* Uses tolerant capability inspection because this module only needs to know
* whether an E2B lifetime applies. Strict credential validation remains at the
* point where the selected sandbox provider is created.
*/
function isLifetimeProvider(): boolean {
return (env.SANDBOX_PROVIDER || 'e2b').toLowerCase() === 'e2b'
return inspectCapability(SANDBOX_CAPABILITY, env).providerId === 'e2b'
}
/**
@@ -1,4 +1,4 @@
import { env } from '@/lib/core/config/env'
import { getConfiguredSandboxProviderId } from '@/lib/core/config/env-capabilities.server'
import { daytonaProvider } from '@/lib/execution/remote-sandbox/daytona'
import { e2bProvider } from '@/lib/execution/remote-sandbox/e2b'
import type { SandboxProvider, SandboxProviderId } from '@/lib/execution/remote-sandbox/types'
@@ -13,11 +13,9 @@ const PROVIDERS: Record<SandboxProviderId, SandboxProvider> = {
daytona: daytonaProvider,
}
const DEFAULT_PROVIDER: SandboxProviderId = 'e2b'
/**
* Resolves which provider serves this execution from the `SANDBOX_PROVIDER` env
* var (defaulting to {@link DEFAULT_PROVIDER}).
* var (defaulting to E2B).
*
* Selection is deliberately resolved ONCE, before the sandbox is created, and is
* never revisited mid-execution: user code has side effects (HTTP calls, S3
@@ -26,16 +24,6 @@ const DEFAULT_PROVIDER: SandboxProviderId = 'e2b'
* `SANDBOX_PROVIDER` and redeploy; in-flight executions are unaffected.
*/
export function resolveProvider(): SandboxProvider {
// Normalize casing identically to env-flags' availability gate — otherwise a
// value like `Daytona` would pass the gate (which lowercases) but miss this
// lowercase-keyed map and throw at create time.
const configured = env.SANDBOX_PROVIDER?.toLowerCase()
if (!configured) return PROVIDERS[DEFAULT_PROVIDER]
const provider = PROVIDERS[configured as SandboxProviderId]
if (!provider) {
throw new Error(
`Unknown SANDBOX_PROVIDER "${env.SANDBOX_PROVIDER}" (expected one of: ${Object.keys(PROVIDERS).join(', ')})`
)
}
return provider
const configured = getConfiguredSandboxProviderId()
return PROVIDERS[configured]
}
@@ -0,0 +1,226 @@
/**
* @vitest-environment node
*/
import { describe, expect, it, vi } from 'vitest'
vi.mock('@/lib/core/config/env', () => ({ env: {} }))
import {
OAUTH_CLIENT_CAPABILITIES,
resolveOAuthClientCapabilityId,
} from '@/lib/core/config/env-capabilities'
import {
getIntegrationTypesForOAuthServiceId,
type IntegrationAvailability,
isOAuthServiceAllowedByIntegrationTypes,
resolveIntegrationAvailability,
resolveIntegrationAvailabilityStateForVisibility,
} from '@/lib/integrations/availability'
import {
isIntegrationDeploymentAvailable,
isIntegrationDeploymentAvailableForVisibility,
} from '@/lib/integrations/availability.server'
import integrationsJson from '@/lib/integrations/integrations.json'
import { SERVICE_ACCOUNT_METADATA_BY_OAUTH_SERVICE_ID } from '@/lib/integrations/service-account-metadata'
import type { Integration } from '@/lib/integrations/types'
import { getServiceConfigByServiceId } from '@/lib/oauth/utils'
const integrations = integrationsJson.integrations as readonly Integration[]
function availabilityFor(
type: string,
values: Parameters<typeof resolveIntegrationAvailability>[0] = {}
): IntegrationAvailability {
const availability = resolveIntegrationAvailability(values).find((item) => item.type === type)
if (!availability) throw new Error(`Missing integration availability for ${type}`)
return availability
}
describe('integration availability', () => {
it('marks a configured OAuth integration ready', () => {
expect(
availabilityFor('slack', {
SLACK_CLIENT_ID: 'client',
SLACK_CLIENT_SECRET: 'secret',
})
).toMatchObject({
name: 'Slack',
slug: 'slack',
state: 'ready',
oauthAvailable: true,
serviceAccountAvailable: false,
missingFields: [],
setupCommand: 'bun run setup integration slack',
})
})
it('marks an integration with an ungated service-account path as limited', () => {
expect(availabilityFor('notion_v2')).toMatchObject({
state: 'limited',
oauthAvailable: false,
serviceAccountAvailable: true,
missingFields: ['NOTION_CLIENT_ID', 'NOTION_CLIENT_SECRET'],
setupCommand: 'bun run setup integration notion',
})
})
it('keeps an ungated service-account path available when OAuth is partial', () => {
expect(availabilityFor('notion_v2', { NOTION_CLIENT_ID: 'client' })).toMatchObject({
state: 'limited',
oauthAvailable: false,
serviceAccountAvailable: true,
missingFields: ['NOTION_CLIENT_SECRET'],
})
})
it('marks an unconfigured OAuth-only integration unavailable', () => {
expect(availabilityFor('x')).toMatchObject({
state: 'unavailable',
oauthAvailable: false,
setupCommand: 'bun run setup integration x',
})
})
it('reports a partially configured OAuth client and its missing fields', () => {
expect(availabilityFor('slack', { SLACK_CLIENT_ID: 'client' })).toMatchObject({
state: 'misconfigured',
oauthAvailable: false,
serviceAccountAvailable: false,
missingFields: ['SLACK_CLIENT_SECRET'],
setupCommand: 'bun run setup integration slack',
})
})
it('projects a revealed preview service-account path as limited', () => {
const unavailableSlack = availabilityFor('slack')
const misconfiguredSlack = availabilityFor('slack', { SLACK_CLIENT_ID: 'client' })
const revealed = {
revealed: new Set(['slack_v2']),
disabled: new Set<string>(),
previewTagged: new Set(['slack_v2']),
}
expect(resolveIntegrationAvailabilityStateForVisibility(unavailableSlack, null)).toBe(
'unavailable'
)
expect(resolveIntegrationAvailabilityStateForVisibility(unavailableSlack, revealed)).toBe(
'limited'
)
expect(resolveIntegrationAvailabilityStateForVisibility(misconfiguredSlack, revealed)).toBe(
'limited'
)
})
it('keeps preview service accounts unavailable when their block is kill-switched', () => {
const unavailableSlack = availabilityFor('slack')
const disabled = {
revealed: new Set(['slack_v2']),
disabled: new Set(['slack_v2']),
previewTagged: new Set(['slack_v2']),
}
expect(resolveIntegrationAvailabilityStateForVisibility(unavailableSlack, disabled)).toBe(
'unavailable'
)
expect(resolveIntegrationAvailabilityStateForVisibility(availabilityFor('x'), disabled)).toBe(
'unavailable'
)
})
it('projects base and versioned deployment availability through explicit visibility', () => {
const revealed = {
revealed: new Set(['slack_v2']),
disabled: new Set<string>(),
previewTagged: new Set(['slack_v2']),
}
const disabled = {
...revealed,
disabled: new Set(['slack_v2']),
}
expect(isIntegrationDeploymentAvailable('slack')).toBe(false)
expect(isIntegrationDeploymentAvailable('slack_v2')).toBe(false)
expect(isIntegrationDeploymentAvailable('slack-v2')).toBe(false)
expect(isIntegrationDeploymentAvailableForVisibility('slack', null)).toBe(false)
expect(isIntegrationDeploymentAvailableForVisibility('slack_v2', null)).toBe(false)
expect(isIntegrationDeploymentAvailableForVisibility('slack-v2', null)).toBe(false)
expect(isIntegrationDeploymentAvailableForVisibility('slack', revealed)).toBe(true)
expect(isIntegrationDeploymentAvailableForVisibility('slack_v2', revealed)).toBe(true)
expect(isIntegrationDeploymentAvailableForVisibility('slack-v2', revealed)).toBe(true)
expect(isIntegrationDeploymentAvailableForVisibility('x', revealed)).toBe(false)
expect(isIntegrationDeploymentAvailableForVisibility('slack', disabled)).toBe(false)
expect(isIntegrationDeploymentAvailableForVisibility('slack_v2', disabled)).toBe(false)
expect(isIntegrationDeploymentAvailableForVisibility('slack-v2', disabled)).toBe(false)
})
it('requires the deployment Trello API key for OAuth and pasted member tokens', () => {
expect(availabilityFor('trello')).toMatchObject({
state: 'unavailable',
serviceAccountAvailable: false,
missingFields: ['TRELLO_API_KEY'],
setupCommand: 'bun run setup integration trello',
})
expect(availabilityFor('trello', { TRELLO_API_KEY: 'trello-key' })).toMatchObject({
state: 'ready',
oauthAvailable: true,
serviceAccountAvailable: true,
missingFields: [],
})
})
it('maps OAuth service ids to the integration allowlist without loading registries', () => {
expect(getIntegrationTypesForOAuthServiceId('gmail')).toContain('gmail_v2')
expect(isOAuthServiceAllowedByIntegrationTypes('gmail', new Set(['slack']))).toBe(false)
expect(isOAuthServiceAllowedByIntegrationTypes('slack', new Set(['slack']))).toBe(true)
expect(isOAuthServiceAllowedByIntegrationTypes('spotify', null)).toBe(true)
})
it('returns every visible integration and only emits accepted setup commands', () => {
const availability = resolveIntegrationAvailability({})
expect(availability).toHaveLength(integrations.length)
for (const integration of availability) {
if (!integration.setupCommand) continue
const capabilityId = integration.setupCommand.replace('bun run setup integration ', '')
expect(Object.hasOwn(OAUTH_CLIENT_CAPABILITIES, capabilityId)).toBe(true)
}
})
it('keeps service-account metadata in parity with canonical OAuth services', () => {
const oauthServiceIds = [
...new Set(
integrations.flatMap((integration) =>
integration.authType === 'oauth' && integration.oauthServiceId
? [integration.oauthServiceId]
: []
)
),
]
const expectedServiceAccountIds: Record<string, string> = {}
for (const oauthServiceId of oauthServiceIds) {
const canonical = getServiceConfigByServiceId(oauthServiceId)
if (!canonical) throw new Error(`Missing canonical OAuth service ${oauthServiceId}`)
const projected = SERVICE_ACCOUNT_METADATA_BY_OAUTH_SERVICE_ID[oauthServiceId]
expect(projected?.providerId, oauthServiceId).toBe(canonical.serviceAccountProviderId)
if (canonical.serviceAccountProviderId) {
expectedServiceAccountIds[oauthServiceId] = canonical.serviceAccountProviderId
}
}
expect(
Object.fromEntries(
Object.entries(SERVICE_ACCOUNT_METADATA_BY_OAUTH_SERVICE_ID).map(
([serviceId, metadata]) => [serviceId, metadata.providerId]
)
)
).toEqual(expectedServiceAccountIds)
expect(SERVICE_ACCOUNT_METADATA_BY_OAUTH_SERVICE_ID.slack.deploymentRequirement).toBe(
'preview-gated'
)
expect(SERVICE_ACCOUNT_METADATA_BY_OAUTH_SERVICE_ID.trello.deploymentRequirement).toBe(
'oauth-client'
)
expect(resolveOAuthClientCapabilityId('trello')).toBe('trello')
})
})
@@ -0,0 +1,96 @@
import { stripVersionSuffix } from '@sim/utils/string'
import type { BlockVisibilityState } from '@/lib/core/config/block-visibility'
import { env } from '@/lib/core/config/env'
import {
inspectOAuthClientCapability,
resolveOAuthClientCapabilityId,
} from '@/lib/core/config/env-capabilities'
import {
type IntegrationAvailability,
resolveIntegrationAvailability,
resolveIntegrationAvailabilityStateForVisibility,
} from '@/lib/integrations/availability'
export type {
IntegrationAvailability,
IntegrationAvailabilityState,
} from '@/lib/integrations/availability'
let unavailableIntegrationTypes: ReadonlySet<string> | null = null
let integrationAvailabilityByType: ReadonlyMap<string, IntegrationAvailability> | null = null
const oauthServiceAvailability = new Map<string, boolean>()
export function getIntegrationAvailability() {
return resolveIntegrationAvailability(env)
}
export function getUnavailableIntegrationTypes(): ReadonlySet<string> {
if (!unavailableIntegrationTypes) {
unavailableIntegrationTypes = new Set(
getIntegrationAvailability()
.filter(
(integration) =>
integration.state === 'unavailable' || integration.state === 'misconfigured'
)
.map((integration) => integration.type.toLowerCase())
)
}
return unavailableIntegrationTypes
}
function getIntegrationAvailabilityByType(): ReadonlyMap<string, IntegrationAvailability> {
if (!integrationAvailabilityByType) {
integrationAvailabilityByType = new Map(
getIntegrationAvailability().map((availability) => [
availability.type.toLowerCase(),
availability,
])
)
}
return integrationAvailabilityByType
}
function getIntegrationAvailabilityForBlockType(
blockType: string
): IntegrationAvailability | undefined {
const normalized = blockType.toLowerCase().replace(/-/g, '_')
const availabilityByType = getIntegrationAvailabilityByType()
return (
availabilityByType.get(normalized) ?? availabilityByType.get(stripVersionSuffix(normalized))
)
}
export function isIntegrationDeploymentAvailable(blockType: string): boolean {
const availability = getIntegrationAvailabilityForBlockType(blockType)
return (
!availability ||
(availability.state !== 'unavailable' && availability.state !== 'misconfigured')
)
}
/**
* Whether an integration can be used in this deployment for the current
* viewer. The cached catalog remains viewer-agnostic; preview service-account
* alternatives are projected against explicitly supplied block visibility.
*/
export function isIntegrationDeploymentAvailableForVisibility(
blockType: string,
visibility: BlockVisibilityState | null
): boolean {
const availability = getIntegrationAvailabilityForBlockType(blockType)
if (!availability) return true
const state = resolveIntegrationAvailabilityStateForVisibility(availability, visibility)
return state !== 'unavailable' && state !== 'misconfigured'
}
export function isOAuthServiceDeploymentAvailable(serviceId: string): boolean {
const normalized = serviceId.toLowerCase()
const cached = oauthServiceAvailability.get(normalized)
if (cached !== undefined) return cached
const capabilityId = resolveOAuthClientCapabilityId(normalized)
const available = capabilityId
? inspectOAuthClientCapability(capabilityId, env).state === 'ready'
: true
oauthServiceAvailability.set(normalized, available)
return available
}
+175
View File
@@ -0,0 +1,175 @@
import type { BlockVisibilityState } from '@/lib/core/config/block-visibility'
import type { EnvCapabilityValues } from '@/lib/core/config/env-capabilities'
import {
inspectOAuthClientCapability,
resolveOAuthClientCapabilityId,
} from '@/lib/core/config/env-capabilities'
import { getServiceAccountGatingBlockType } from '@/lib/credentials/service-account-provider-ids'
import integrationsJson from '@/lib/integrations/integrations.json'
import { getServiceAccountMetadata } from '@/lib/integrations/service-account-metadata'
import { isHiddenUnder } from '@/blocks/visibility/context'
export type IntegrationAvailabilityState = 'ready' | 'limited' | 'unavailable' | 'misconfigured'
export interface IntegrationAvailability {
type: string
slug: string
name: string
state: IntegrationAvailabilityState
oauthAvailable: boolean
serviceAccountAvailable: boolean
missingFields: readonly string[]
setupCommand?: string
}
interface DeploymentIntegration {
type: string
slug: string
name: string
authType: 'oauth' | 'api-key' | 'none'
oauthServiceId?: string
}
const integrations = integrationsJson.integrations as readonly DeploymentIntegration[]
const deploymentGatedIntegrationTypes = new Set(
integrations
.filter((integration) => integration.authType === 'oauth')
.map((integration) => integration.type.toLowerCase())
)
const integrationTypesByOAuthServiceId = new Map<string, readonly string[]>()
const previewServiceAccountGatesByIntegrationType = new Map<string, string>()
for (const integration of integrations) {
if (integration.authType !== 'oauth' || !integration.oauthServiceId) continue
const serviceId = integration.oauthServiceId.toLowerCase()
const current = integrationTypesByOAuthServiceId.get(serviceId) ?? []
const integrationType = integration.type.toLowerCase()
integrationTypesByOAuthServiceId.set(serviceId, [...current, integrationType])
const serviceAccount = getServiceAccountMetadata(serviceId)
if (serviceAccount?.deploymentRequirement !== 'preview-gated') continue
const gatingBlockType = getServiceAccountGatingBlockType(serviceAccount.providerId)
if (!gatingBlockType) {
throw new Error(
`Preview-gated service account ${serviceAccount.providerId} has no gating block type`
)
}
previewServiceAccountGatesByIntegrationType.set(integrationType, gatingBlockType)
}
export function isDeploymentGatedIntegrationType(blockType: string): boolean {
return deploymentGatedIntegrationTypes.has(blockType.toLowerCase())
}
/** Returns the generated integration block types authenticated by one OAuth service entry. */
export function getIntegrationTypesForOAuthServiceId(serviceId: string): readonly string[] {
return integrationTypesByOAuthServiceId.get(serviceId.toLowerCase()) ?? []
}
/** Applies an integration allowlist to an OAuth service without loading executable registries. */
export function isOAuthServiceAllowedByIntegrationTypes(
serviceId: string,
allowedIntegrationTypes: ReadonlySet<string> | null
): boolean {
if (allowedIntegrationTypes === null) return true
const integrationTypes = getIntegrationTypesForOAuthServiceId(serviceId)
return (
integrationTypes.length === 0 ||
integrationTypes.some((blockType) => allowedIntegrationTypes.has(blockType))
)
}
interface IntegrationAvailabilitySummary {
type: string
state: IntegrationAvailabilityState
oauthAvailable: boolean
}
/**
* Projects deployment availability through the current viewer's block gate.
* A revealed preview service-account path makes an OAuth-unavailable
* integration limited rather than unavailable; the OAuth path itself remains
* disabled. The shared hidden predicate keeps preview and kill-switch behavior
* identical to every other block discovery surface.
*/
export function resolveIntegrationAvailabilityStateForVisibility(
availability: IntegrationAvailabilitySummary,
visibility: BlockVisibilityState | null
): IntegrationAvailabilityState {
const gatingBlockType = previewServiceAccountGatesByIntegrationType.get(
availability.type.toLowerCase()
)
if (!gatingBlockType || isHiddenUnder(visibility, { type: gatingBlockType, preview: true })) {
return availability.state
}
return availability.oauthAvailable ? 'ready' : 'limited'
}
function resolveOAuthIntegrationAvailability(
integration: DeploymentIntegration,
values: EnvCapabilityValues
): IntegrationAvailability {
const { oauthServiceId } = integration
if (!oauthServiceId) {
throw new Error(`OAuth integration ${integration.slug} is missing oauthServiceId`)
}
const capabilityId = resolveOAuthClientCapabilityId(oauthServiceId)
const serviceAccount = getServiceAccountMetadata(oauthServiceId)
if (!capabilityId) {
throw new Error(
`OAuth integration ${integration.slug} has no OAuth client capability definition`
)
}
const oauth = inspectOAuthClientCapability(capabilityId, values)
const setupCommand = `bun run setup integration ${capabilityId}`
const serviceAccountAvailable = Boolean(
serviceAccount &&
serviceAccount.deploymentRequirement !== 'preview-gated' &&
(serviceAccount.deploymentRequirement !== 'oauth-client' || oauth.state === 'ready')
)
const state: IntegrationAvailabilityState =
oauth.state === 'ready'
? 'ready'
: serviceAccountAvailable
? 'limited'
: oauth.state === 'partial' || oauth.state === 'invalid'
? 'misconfigured'
: 'unavailable'
return {
type: integration.type,
slug: integration.slug,
name: integration.name,
state,
oauthAvailable: oauth.state === 'ready',
serviceAccountAvailable,
missingFields: oauth.missingFields,
setupCommand,
}
}
/**
* Resolves deployment availability for every integration in the generated
* catalog using only caller-supplied environment values and pure metadata.
*/
export function resolveIntegrationAvailability(
values: EnvCapabilityValues
): readonly IntegrationAvailability[] {
return integrations.map((integration) => {
if (integration.authType === 'oauth') {
return resolveOAuthIntegrationAvailability(integration, values)
}
return {
type: integration.type,
slug: integration.slug,
name: integration.name,
state: 'ready',
oauthAvailable: false,
serviceAccountAvailable: false,
missingFields: [],
}
})
}
@@ -0,0 +1,177 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { IntegrationAvailability } from '@/lib/integrations/availability'
import type { OAuthServiceMetadata } from '@/lib/oauth/types'
const { getBlockMock, getIntegrationAvailabilityMock } = vi.hoisted(() => ({
getBlockMock: vi.fn(),
getIntegrationAvailabilityMock: vi.fn(),
}))
vi.mock('@/blocks/registry', () => ({ getBlock: getBlockMock }))
vi.mock('@/lib/integrations/availability.server', () => ({
getIntegrationAvailability: getIntegrationAvailabilityMock,
isOAuthServiceDeploymentAvailable: vi.fn(() => true),
}))
import { createIntegrationCredentialVisibility } from '@/lib/integrations/credential-visibility.server'
const SERVICES: readonly OAuthServiceMetadata[] = [
{
serviceId: 'notion',
providerId: 'notion',
serviceAccountProviderId: 'notion-service-account',
name: 'Notion',
description: 'Notion workspace',
baseProvider: 'notion',
authType: 'oauth',
},
{
serviceId: 'slack',
providerId: 'slack',
serviceAccountProviderId: 'slack-custom-bot',
name: 'Slack',
description: 'Slack workspace',
baseProvider: 'slack',
authType: 'oauth',
},
]
function availability(
type: string,
state: IntegrationAvailability['state'],
options: Pick<IntegrationAvailability, 'oauthAvailable' | 'serviceAccountAvailable'>
): IntegrationAvailability {
return {
type,
slug: type,
name: type,
state,
missingFields: [],
...options,
}
}
describe('integration credential visibility', () => {
beforeEach(() => {
vi.clearAllMocks()
getBlockMock.mockImplementation((type: string) => ({
type,
...(type === 'slack_v2' ? { preview: true } : {}),
}))
getIntegrationAvailabilityMock.mockReturnValue([
availability('notion_v2', 'limited', {
oauthAvailable: false,
serviceAccountAvailable: true,
}),
availability('slack', 'unavailable', {
oauthAvailable: false,
serviceAccountAvailable: false,
}),
])
})
it('applies the integration allowlist to OAuth and service-account credentials', () => {
const visibility = createIntegrationCredentialVisibility({
allowedIntegrationTypes: new Set(['slack']),
blockVisibility: null,
oauthServices: SERVICES,
})
expect(visibility.isCredentialVisible({ providerId: 'notion', type: 'oauth' })).toBe(false)
expect(
visibility.isCredentialVisible({
providerId: 'notion-service-account',
type: 'service_account',
})
).toBe(false)
})
it('keeps an independent service-account fallback when OAuth is unavailable', () => {
const visibility = createIntegrationCredentialVisibility({
allowedIntegrationTypes: new Set(['notion_v2']),
blockVisibility: null,
oauthServices: SERVICES,
})
expect(visibility.isCredentialVisible({ providerId: 'notion', type: 'oauth' })).toBe(false)
expect(
visibility.isCredentialVisible({
providerId: 'notion-service-account',
type: 'service_account',
})
).toBe(true)
})
it('requires the Slack preview reveal for custom-bot credentials', () => {
const hidden = createIntegrationCredentialVisibility({
allowedIntegrationTypes: new Set(['slack']),
blockVisibility: null,
oauthServices: SERVICES,
})
const revealed = createIntegrationCredentialVisibility({
allowedIntegrationTypes: new Set(['slack']),
blockVisibility: {
revealed: new Set(['slack_v2']),
disabled: new Set(),
previewTagged: new Set(['slack_v2']),
},
oauthServices: SERVICES,
})
const credential = { providerId: 'slack-custom-bot', type: 'service_account' } as const
expect(hidden.isCredentialVisible(credential)).toBe(false)
expect(revealed.isCredentialVisible(credential)).toBe(true)
})
it('projects partial OAuth state through a revealed service-account preview', () => {
getIntegrationAvailabilityMock.mockReturnValue([
availability('slack', 'misconfigured', {
oauthAvailable: false,
serviceAccountAvailable: false,
}),
])
const visibility = createIntegrationCredentialVisibility({
allowedIntegrationTypes: new Set(['slack']),
blockVisibility: {
revealed: new Set(['slack_v2']),
disabled: new Set(),
previewTagged: new Set(['slack_v2']),
},
oauthServices: SERVICES,
})
const disabled = createIntegrationCredentialVisibility({
allowedIntegrationTypes: new Set(['slack']),
blockVisibility: {
revealed: new Set(['slack_v2']),
disabled: new Set(['slack_v2']),
previewTagged: new Set(['slack_v2']),
},
oauthServices: SERVICES,
})
const credential = {
providerId: 'slack-custom-bot',
type: 'service_account',
} as const
expect(visibility.isCredentialVisible(credential)).toBe(true)
expect(disabled.isCredentialVisible(credential)).toBe(false)
})
it('leaves non-integration credentials visible', () => {
const visibility = createIntegrationCredentialVisibility({
allowedIntegrationTypes: new Set(),
blockVisibility: null,
oauthServices: SERVICES,
})
expect(
visibility.isCredentialVisible({
providerId: 'claude-platform',
type: 'service_account',
})
).toBe(true)
})
})
@@ -0,0 +1,142 @@
import type { BlockVisibilityState } from '@/lib/core/config/block-visibility'
import { getServiceAccountGatingBlockType } from '@/lib/credentials/service-account-provider-ids'
import {
getIntegrationTypesForOAuthServiceId,
isOAuthServiceAllowedByIntegrationTypes,
resolveIntegrationAvailabilityStateForVisibility,
} from '@/lib/integrations/availability'
import {
getIntegrationAvailability,
isOAuthServiceDeploymentAvailable,
} from '@/lib/integrations/availability.server'
import type { OAuthServiceMetadata } from '@/lib/oauth/types'
import { getAllOAuthServices } from '@/lib/oauth/utils'
import { getBlock } from '@/blocks/registry'
import { isHiddenUnder } from '@/blocks/visibility/context'
export interface IntegrationCredentialIdentity {
providerId: string
type?: 'oauth' | 'service_account'
}
interface IntegrationCredentialVisibilityOptions {
allowedIntegrationTypes: ReadonlySet<string> | null
blockVisibility: BlockVisibilityState | null
oauthServices?: readonly OAuthServiceMetadata[]
}
export interface IntegrationCredentialVisibility {
isCredentialVisible: (credential: IntegrationCredentialIdentity) => boolean
isOAuthServiceVisible: (service: OAuthServiceMetadata) => boolean
}
/**
* Builds the server-side projection shared by Copilot credential discovery and
* the workspace VFS. Unknown provider ids are not integration credentials and
* remain visible; mapped OAuth and service-account ids must have at least one
* owning integration that is allowed, deployment-ready, and visible to the
* current block-visibility projection.
*/
export function createIntegrationCredentialVisibility({
allowedIntegrationTypes,
blockVisibility,
oauthServices = getAllOAuthServices(),
}: IntegrationCredentialVisibilityOptions): IntegrationCredentialVisibility {
const oauthOwners = oauthServices.filter((service) => service.authType === 'oauth')
const oauthOwnersByProviderId = new Map<string, OAuthServiceMetadata[]>()
const serviceAccountOwnersByProviderId = new Map<string, OAuthServiceMetadata[]>()
const availabilityByType = new Map(
getIntegrationAvailability().map((availability) => [
availability.type.toLowerCase(),
availability,
])
)
const addOwner = (
ownersByProviderId: Map<string, OAuthServiceMetadata[]>,
providerId: string,
service: OAuthServiceMetadata
) => {
const owners = ownersByProviderId.get(providerId)
if (owners) owners.push(service)
else ownersByProviderId.set(providerId, [service])
}
for (const service of oauthOwners) {
addOwner(oauthOwnersByProviderId, service.providerId, service)
if (service.serviceAccountProviderId) {
addOwner(serviceAccountOwnersByProviderId, service.serviceAccountProviderId, service)
}
}
const isServiceAllowed = (service: OAuthServiceMetadata) =>
isOAuthServiceAllowedByIntegrationTypes(service.serviceId, allowedIntegrationTypes)
const visibleAvailability = (service: OAuthServiceMetadata) => {
return getIntegrationTypesForOAuthServiceId(service.serviceId).flatMap((blockType) => {
const block = getBlock(blockType)
if (!block || block.hideFromToolbar || isHiddenUnder(blockVisibility, block)) return []
const availability = availabilityByType.get(blockType.toLowerCase())
return availability ? [availability] : []
})
}
const isOAuthServiceVisible = (service: OAuthServiceMetadata): boolean => {
if (service.authType !== 'oauth' || !isServiceAllowed(service)) return false
const integrationTypes = getIntegrationTypesForOAuthServiceId(service.serviceId)
if (integrationTypes.length === 0) {
return isOAuthServiceDeploymentAvailable(service.providerId)
}
return visibleAvailability(service).some(
(availability) => availability.state === 'ready' && availability.oauthAvailable
)
}
const isServiceAccountVisible = (
providerId: string,
owners: readonly OAuthServiceMetadata[]
): boolean => {
const gatingBlockType = getServiceAccountGatingBlockType(providerId)
if (gatingBlockType) {
const gatingBlock = getBlock(gatingBlockType)
if (!gatingBlock || isHiddenUnder(blockVisibility, gatingBlock)) return false
return owners.some(
(service) =>
isServiceAllowed(service) &&
visibleAvailability(service).some((availability) => {
const state = resolveIntegrationAvailabilityStateForVisibility(
availability,
blockVisibility
)
return state === 'ready' || state === 'limited'
})
)
}
return owners.some(
(service) =>
isServiceAllowed(service) &&
visibleAvailability(service).some(
(availability) =>
availability.serviceAccountAvailable &&
(availability.state === 'ready' || availability.state === 'limited')
)
)
}
const isCredentialVisible = ({ providerId, type }: IntegrationCredentialIdentity): boolean => {
if (type !== 'service_account') {
const owners = oauthOwnersByProviderId.get(providerId)
if (owners) return owners.some(isOAuthServiceVisible)
}
if (type !== 'oauth') {
const owners = serviceAccountOwnersByProviderId.get(providerId)
if (owners) return isServiceAccountVisible(providerId, owners)
}
return true
}
return { isCredentialVisible, isOAuthServiceVisible }
}
@@ -0,0 +1,61 @@
/**
* Lightweight deployment metadata for OAuth services that also accept a
* user-supplied service-account credential.
*
* This projection deliberately contains no icons, scopes, or OAuth runtime
* configuration so deployment tooling can inspect integration availability
* without loading the executable integration graph. Its parity with the
* canonical OAuth service configuration is enforced by an invariant test.
*/
export interface ServiceAccountMetadata {
providerId: string
deploymentRequirement?: 'preview-gated' | 'oauth-client'
}
export const SERVICE_ACCOUNT_METADATA_BY_OAUTH_SERVICE_ID: Readonly<
Record<string, ServiceAccountMetadata>
> = {
airtable: { providerId: 'airtable-service-account' },
asana: { providerId: 'asana-service-account' },
attio: { providerId: 'attio-service-account' },
box: { providerId: 'box-service-account' },
calcom: { providerId: 'calcom-service-account' },
clickup: { providerId: 'clickup-service-account' },
confluence: { providerId: 'atlassian-service-account' },
gmail: { providerId: 'google-service-account' },
'google-bigquery': { providerId: 'google-service-account' },
'google-calendar': { providerId: 'google-service-account' },
'google-contacts': { providerId: 'google-service-account' },
'google-docs': { providerId: 'google-service-account' },
'google-drive': { providerId: 'google-service-account' },
'google-forms': { providerId: 'google-service-account' },
'google-groups': { providerId: 'google-service-account' },
'google-meet': { providerId: 'google-service-account' },
'google-sheets': { providerId: 'google-service-account' },
'google-tasks': { providerId: 'google-service-account' },
'google-vault': { providerId: 'google-service-account' },
hubspot: { providerId: 'hubspot-service-account' },
jira: { providerId: 'atlassian-service-account' },
linear: { providerId: 'linear-service-account' },
monday: { providerId: 'monday-service-account' },
notion: { providerId: 'notion-service-account' },
pipedrive: { providerId: 'pipedrive-service-account' },
salesforce: { providerId: 'salesforce-service-account' },
shopify: { providerId: 'shopify-service-account' },
slack: { providerId: 'slack-custom-bot', deploymentRequirement: 'preview-gated' },
trello: { providerId: 'trello-service-account', deploymentRequirement: 'oauth-client' },
wealthbox: { providerId: 'wealthbox-service-account' },
webflow: { providerId: 'webflow-service-account' },
'zoho-desk': { providerId: 'zoho-desk-service-account' },
zoom: { providerId: 'zoom-service-account' },
} as const
export function getServiceAccountMetadata(
oauthServiceId: string
): ServiceAccountMetadata | undefined {
if (!Object.hasOwn(SERVICE_ACCOUNT_METADATA_BY_OAUTH_SERVICE_ID, oauthServiceId)) {
return undefined
}
return SERVICE_ACCOUNT_METADATA_BY_OAUTH_SERVICE_ID[oauthServiceId]
}
@@ -15,6 +15,7 @@ import {
} from '@/lib/chunkers'
import type { ChunkingStrategy, StrategyOptions } from '@/lib/chunkers/types'
import { env, envNumber } from '@/lib/core/config/env'
import { OCR_CAPABILITY, requireCapability } from '@/lib/core/config/env-capabilities'
import { parseBuffer } from '@/lib/file-parsers'
import type { FileParseMetadata } from '@/lib/file-parsers/types'
import { resolveParserExtension } from '@/lib/knowledge/documents/parser-extension'
@@ -287,19 +288,23 @@ async function parseDocument(
metadata?: FileParseMetadata
}> {
const isPDF = mimeType === 'application/pdf'
const hasAzureMistralOCR =
env.OCR_AZURE_API_KEY && env.OCR_AZURE_ENDPOINT && env.OCR_AZURE_MODEL_NAME
const mistralApiKey = await getMistralApiKey(workspaceId)
const hasMistralOCR = !!mistralApiKey
if (isPDF && (hasAzureMistralOCR || hasMistralOCR)) {
if (hasAzureMistralOCR) {
if (isPDF) {
const ocrProvider = requireCapability(OCR_CAPABILITY, {
OCR_PROVIDER: env.OCR_PROVIDER,
OCR_AZURE_API_KEY: env.OCR_AZURE_API_KEY,
OCR_AZURE_ENDPOINT: env.OCR_AZURE_ENDPOINT,
OCR_AZURE_MODEL_NAME: env.OCR_AZURE_MODEL_NAME,
MISTRAL_API_KEY: mistralApiKey,
}).providerId
if (ocrProvider === 'azure-mistral') {
logger.info(`Using Azure Mistral OCR: ${filename}`)
return parseWithAzureMistralOCR(fileUrl, filename, mimeType, userId)
}
if (hasMistralOCR) {
if (ocrProvider === 'mistral') {
logger.info(`Using Mistral OCR: ${filename}`)
return parseWithMistralOCR(fileUrl, filename, mimeType, userId, workspaceId, mistralApiKey)
}
@@ -499,11 +504,9 @@ async function parseWithAzureMistralOCR(
if (mimeType === 'application/pdf') {
const pageCount = await getPdfPageCount(fileBuffer)
if (pageCount > MISTRAL_MAX_PAGES) {
logger.info(
`PDF has ${pageCount} pages, exceeds Azure OCR limit of ${MISTRAL_MAX_PAGES}. ` +
`Falling back to file parser.`
throw new Error(
`PDF has ${pageCount} pages, exceeding the Azure OCR limit of ${MISTRAL_MAX_PAGES}`
)
return parseWithFileParser(fileUrl, filename, mimeType, userId)
}
logger.info(`Azure Mistral OCR: PDF page count for ${filename}: ${pageCount}`)
}
@@ -545,9 +548,7 @@ async function parseWithAzureMistralOCR(
logger.error(`Azure Mistral OCR failed for ${filename}:`, {
message: toError(error).message,
})
logger.info(`Falling back to file parser: ${filename}`)
return parseWithFileParser(fileUrl, filename, mimeType, userId)
throw error
}
}
@@ -605,9 +606,7 @@ async function parseWithMistralOCR(
logger.error(`Mistral OCR failed for ${filename}:`, {
message: toError(error).message,
})
logger.info(`Falling back to file parser: ${filename}`)
return parseWithFileParser(fileUrl, filename, mimeType, userId)
throw error
}
}
+8 -13
View File
@@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { getAccessControlConfig, isEmailBlockedByAccessControl } from '@/lib/auth/access-control'
import { processEmailData, shouldSkipForUnsubscribe } from '@/lib/messaging/email/prepare'
import { activeProviders } from '@/lib/messaging/email/providers'
import { activeProviders, emailFallback } from '@/lib/messaging/email/providers'
import type {
BatchEmailOptions,
BatchSendEmailResult,
@@ -103,20 +103,15 @@ export async function sendEmail(options: EmailOptions): Promise<SendEmailResult>
}
async function dispatchWithFallback(data: ProcessedEmailData): Promise<SendEmailResult> {
let lastError: unknown
for (const provider of activeProviders) {
try {
return await provider.send(data)
} catch (error) {
lastError = error
logger.warn(`${provider.name} failed, trying next provider`, error)
try {
return await emailFallback.execute((provider) => provider.send(data))
} catch (error) {
logger.error('All email providers failed', error)
return {
success: false,
message: getErrorMessage(error, 'All email providers failed'),
}
}
logger.error('All email providers failed', lastError)
return {
success: false,
message: `All email providers failed: ${getErrorMessage(lastError, 'unknown error')}`,
}
}
interface PreparedBatchEntry {
+17 -18
View File
@@ -1,4 +1,6 @@
import { createLogger } from '@sim/logger'
import { EMAIL_CAPABILITY, type FallbackFactories } from '@/lib/core/config/env-capabilities'
import { wireServerFallback } from '@/lib/core/config/env-capabilities.server'
import { createAzureProvider } from '@/lib/messaging/email/providers/azure'
import { createGmailProvider } from '@/lib/messaging/email/providers/gmail'
import { createResendProvider } from '@/lib/messaging/email/providers/resend'
@@ -8,23 +10,20 @@ import type { MailProvider } from '@/lib/messaging/email/types'
const logger = createLogger('MailProviders')
const factories = [
createResendProvider,
createSesProvider,
createSmtpProvider,
createAzureProvider,
createGmailProvider,
] as const
const factories = {
resend: createResendProvider,
ses: createSesProvider,
smtp: createSmtpProvider,
azure: createAzureProvider,
gmail: createGmailProvider,
} satisfies FallbackFactories<typeof EMAIL_CAPABILITY, MailProvider>
function safeCreate(factory: () => MailProvider | null): MailProvider | null {
try {
return factory()
} catch (error) {
logger.error('Mail provider factory threw at startup; skipping', error)
return null
}
}
export const emailFallback = wireServerFallback<typeof EMAIL_CAPABILITY, MailProvider>({
definition: EMAIL_CAPABILITY,
factories,
onFailure(providerId, error) {
logger.warn(`${providerId} failed, trying next provider`, error)
},
})
export const activeProviders: readonly MailProvider[] = factories
.map((factory) => safeCreate(factory))
.filter((provider): provider is MailProvider => provider !== null)
export const activeProviders: readonly MailProvider[] = emailFallback.providers
+38
View File
@@ -55,6 +55,9 @@ beforeAll(() => {
WORDPRESS_CLIENT_SECRET: 'wordpress_client_secret',
SPOTIFY_CLIENT_ID: 'spotify_client_id',
SPOTIFY_CLIENT_SECRET: 'spotify_client_secret',
CALCOM_CLIENT_ID: 'calcom_client_id',
MONDAY_CLIENT_ID: 'monday_client_id',
MONDAY_CLIENT_SECRET: undefined,
})
})
@@ -296,6 +299,26 @@ describe('OAuth Token Refresh', () => {
expect(bodyParams.get('client_id')).toBeNull()
})
it.concurrent('should preserve Cal.com bearer refresh authentication', async () => {
const mockFetch = createMockFetch(defaultOAuthResponse)
const refreshToken = 'test_refresh_token'
await withMockFetch(mockFetch, () => refreshOAuthToken('calcom', refreshToken))
const [endpoint, requestOptions] = mockFetch.mock.calls[0] as [
string,
{ headers: Record<string, string>; body: string },
]
const bodyParams = new URLSearchParams(requestOptions.body)
expect(endpoint).toBe('https://app.cal.com/api/auth/oauth/refreshToken')
expect(requestOptions.headers.Authorization).toBe(`Bearer ${refreshToken}`)
expect(bodyParams.get('grant_type')).toBe('refresh_token')
expect(bodyParams.get('client_id')).toBe('calcom_client_id')
expect(bodyParams.get('client_secret')).toBeNull()
expect(bodyParams.get('refresh_token')).toBeNull()
})
it.concurrent('should send Notion request with Basic Auth header and JSON body', async () => {
const mockFetch = createMockFetch(defaultOAuthResponse)
const refreshToken = 'test_refresh_token'
@@ -351,6 +374,21 @@ describe('OAuth Token Refresh', () => {
})
describe('Error Handling', () => {
it.concurrent('should return the canonical error for partial OAuth configuration', async () => {
const mockFetch = createMockFetch(defaultOAuthResponse)
const result = await withMockFetch(mockFetch, () =>
refreshOAuthToken('monday', 'test_refresh_token')
)
expect(result).toEqual({
ok: false,
message:
'OAuth client monday is partially configured — missing MONDAY_CLIENT_SECRET. Run bun run setup integration monday.',
})
expect(mockFetch).not.toHaveBeenCalled()
})
it.concurrent('should return failure for unsupported provider', async () => {
const mockFetch = createMockFetch(defaultOAuthResponse)
const refreshToken = 'test_refresh_token'
+147 -92
View File
@@ -59,6 +59,11 @@ import {
ZoomIcon,
} from '@/components/icons'
import { env } from '@/lib/core/config/env'
import {
type OAuthClientCapabilityField,
type OAuthClientCapabilityId,
requireOAuthClientCapability,
} from '@/lib/core/config/env-capabilities'
import { isSlackExtendedScopesEnabled } from '@/lib/core/config/env-flags'
import {
DEFAULT_MAX_ERROR_BODY_BYTES,
@@ -1249,22 +1254,28 @@ interface ProviderAuthConfig {
clientIdParamName?: string
}
function getConfiguredClientCredentials<const TCapabilityId extends OAuthClientCapabilityId>(
providerId: TCapabilityId,
clientIdField: NoInfer<OAuthClientCapabilityField<TCapabilityId>>,
clientSecretField?: NoInfer<OAuthClientCapabilityField<TCapabilityId>>
): Pick<ProviderAuthConfig, 'clientId' | 'clientSecret'> {
const { values } = requireOAuthClientCapability(providerId, env)
return {
clientId: values[clientIdField],
clientSecret: clientSecretField ? values[clientSecretField] : '',
}
}
/**
* Get OAuth provider configuration for token refresh
*/
function getProviderAuthConfig(provider: string): ProviderAuthConfig {
const getCredentials = (clientId: string | undefined, clientSecret: string | undefined) => {
if (!clientId || !clientSecret) {
throw new Error(`Missing client credentials for provider: ${provider}`)
}
return { clientId, clientSecret }
}
switch (provider) {
case 'google': {
const { clientId, clientSecret } = getCredentials(
env.GOOGLE_CLIENT_ID,
env.GOOGLE_CLIENT_SECRET
const { clientId, clientSecret } = getConfiguredClientCredentials(
'google',
'GOOGLE_CLIENT_ID',
'GOOGLE_CLIENT_SECRET'
)
return {
tokenEndpoint: 'https://oauth2.googleapis.com/token',
@@ -1274,7 +1285,11 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig {
}
}
case 'x': {
const { clientId, clientSecret } = getCredentials(env.X_CLIENT_ID, env.X_CLIENT_SECRET)
const { clientId, clientSecret } = getConfiguredClientCredentials(
'x',
'X_CLIENT_ID',
'X_CLIENT_SECRET'
)
return {
tokenEndpoint: 'https://api.x.com/2/oauth2/token',
clientId,
@@ -1284,9 +1299,10 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig {
}
}
case 'tiktok': {
const { clientId, clientSecret } = getCredentials(
env.TIKTOK_CLIENT_ID,
env.TIKTOK_CLIENT_SECRET
const { clientId, clientSecret } = getConfiguredClientCredentials(
'tiktok',
'TIKTOK_CLIENT_ID',
'TIKTOK_CLIENT_SECRET'
)
return {
tokenEndpoint: 'https://open.tiktokapis.com/v2/oauth/token/',
@@ -1299,9 +1315,10 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig {
}
}
case 'confluence': {
const { clientId, clientSecret } = getCredentials(
env.CONFLUENCE_CLIENT_ID,
env.CONFLUENCE_CLIENT_SECRET
const { clientId, clientSecret } = getConfiguredClientCredentials(
'confluence',
'CONFLUENCE_CLIENT_ID',
'CONFLUENCE_CLIENT_SECRET'
)
return {
tokenEndpoint: 'https://auth.atlassian.com/oauth/token',
@@ -1312,7 +1329,11 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig {
}
}
case 'jira': {
const { clientId, clientSecret } = getCredentials(env.JIRA_CLIENT_ID, env.JIRA_CLIENT_SECRET)
const { clientId, clientSecret } = getConfiguredClientCredentials(
'jira',
'JIRA_CLIENT_ID',
'JIRA_CLIENT_SECRET'
)
return {
tokenEndpoint: 'https://auth.atlassian.com/oauth/token',
clientId,
@@ -1322,14 +1343,14 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig {
}
}
case 'calcom': {
const clientId = env.CALCOM_CLIENT_ID
if (!clientId) {
throw new Error('Missing CALCOM_CLIENT_ID')
}
const { clientId, clientSecret } = getConfiguredClientCredentials(
'calcom',
'CALCOM_CLIENT_ID'
)
return {
tokenEndpoint: 'https://app.cal.com/api/auth/oauth/refreshToken',
clientId,
clientSecret: '',
clientSecret,
useBasicAuth: false,
supportsRefreshTokenRotation: true,
// Cal.com requires refresh token in Authorization header, not body
@@ -1337,9 +1358,10 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig {
}
}
case 'airtable': {
const { clientId, clientSecret } = getCredentials(
env.AIRTABLE_CLIENT_ID,
env.AIRTABLE_CLIENT_SECRET
const { clientId, clientSecret } = getConfiguredClientCredentials(
'airtable',
'AIRTABLE_CLIENT_ID',
'AIRTABLE_CLIENT_SECRET'
)
return {
tokenEndpoint: 'https://airtable.com/oauth2/v1/token',
@@ -1350,9 +1372,10 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig {
}
}
case 'notion': {
const { clientId, clientSecret } = getCredentials(
env.NOTION_CLIENT_ID,
env.NOTION_CLIENT_SECRET
const { clientId, clientSecret } = getConfiguredClientCredentials(
'notion',
'NOTION_CLIENT_ID',
'NOTION_CLIENT_SECRET'
)
return {
tokenEndpoint: 'https://api.notion.com/v1/oauth/token',
@@ -1367,9 +1390,10 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig {
case 'outlook':
case 'onedrive':
case 'sharepoint': {
const { clientId, clientSecret } = getCredentials(
env.MICROSOFT_CLIENT_ID,
env.MICROSOFT_CLIENT_SECRET
const { clientId, clientSecret } = getConfiguredClientCredentials(
'microsoft',
'MICROSOFT_CLIENT_ID',
'MICROSOFT_CLIENT_SECRET'
)
return {
tokenEndpoint: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
@@ -1380,9 +1404,10 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig {
}
}
case 'clickup': {
const { clientId, clientSecret } = getCredentials(
env.CLICKUP_CLIENT_ID,
env.CLICKUP_CLIENT_SECRET
const { clientId, clientSecret } = getConfiguredClientCredentials(
'clickup',
'CLICKUP_CLIENT_ID',
'CLICKUP_CLIENT_SECRET'
)
return {
tokenEndpoint: 'https://api.clickup.com/api/v2/oauth/token',
@@ -1393,9 +1418,10 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig {
}
}
case 'linear': {
const { clientId, clientSecret } = getCredentials(
env.LINEAR_CLIENT_ID,
env.LINEAR_CLIENT_SECRET
const { clientId, clientSecret } = getConfiguredClientCredentials(
'linear',
'LINEAR_CLIENT_ID',
'LINEAR_CLIENT_SECRET'
)
return {
tokenEndpoint: 'https://api.linear.app/oauth/token',
@@ -1406,9 +1432,10 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig {
}
}
case 'attio': {
const { clientId, clientSecret } = getCredentials(
env.ATTIO_CLIENT_ID,
env.ATTIO_CLIENT_SECRET
const { clientId, clientSecret } = getConfiguredClientCredentials(
'attio',
'ATTIO_CLIENT_ID',
'ATTIO_CLIENT_SECRET'
)
return {
tokenEndpoint: 'https://app.attio.com/oauth/token',
@@ -1418,7 +1445,11 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig {
}
}
case 'box': {
const { clientId, clientSecret } = getCredentials(env.BOX_CLIENT_ID, env.BOX_CLIENT_SECRET)
const { clientId, clientSecret } = getConfiguredClientCredentials(
'box',
'BOX_CLIENT_ID',
'BOX_CLIENT_SECRET'
)
return {
tokenEndpoint: 'https://api.box.com/oauth2/token',
clientId,
@@ -1427,9 +1458,10 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig {
}
}
case 'docusign': {
const { clientId, clientSecret } = getCredentials(
env.DOCUSIGN_CLIENT_ID,
env.DOCUSIGN_CLIENT_SECRET
const { clientId, clientSecret } = getConfiguredClientCredentials(
'docusign',
'DOCUSIGN_CLIENT_ID',
'DOCUSIGN_CLIENT_SECRET'
)
return {
tokenEndpoint: 'https://account-d.docusign.com/oauth/token',
@@ -1440,9 +1472,10 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig {
}
}
case 'dropbox': {
const { clientId, clientSecret } = getCredentials(
env.DROPBOX_CLIENT_ID,
env.DROPBOX_CLIENT_SECRET
const { clientId, clientSecret } = getConfiguredClientCredentials(
'dropbox',
'DROPBOX_CLIENT_ID',
'DROPBOX_CLIENT_SECRET'
)
return {
tokenEndpoint: 'https://api.dropboxapi.com/oauth2/token',
@@ -1453,9 +1486,10 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig {
}
}
case 'slack': {
const { clientId, clientSecret } = getCredentials(
env.SLACK_CLIENT_ID,
env.SLACK_CLIENT_SECRET
const { clientId, clientSecret } = getConfiguredClientCredentials(
'slack',
'SLACK_CLIENT_ID',
'SLACK_CLIENT_SECRET'
)
return {
tokenEndpoint: 'https://slack.com/api/oauth.v2.access',
@@ -1466,9 +1500,10 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig {
}
}
case 'reddit': {
const { clientId, clientSecret } = getCredentials(
env.REDDIT_CLIENT_ID,
env.REDDIT_CLIENT_SECRET
const { clientId, clientSecret } = getConfiguredClientCredentials(
'reddit',
'REDDIT_CLIENT_ID',
'REDDIT_CLIENT_SECRET'
)
return {
tokenEndpoint: 'https://www.reddit.com/api/v1/access_token',
@@ -1481,9 +1516,10 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig {
}
}
case 'wealthbox': {
const { clientId, clientSecret } = getCredentials(
env.WEALTHBOX_CLIENT_ID,
env.WEALTHBOX_CLIENT_SECRET
const { clientId, clientSecret } = getConfiguredClientCredentials(
'wealthbox',
'WEALTHBOX_CLIENT_ID',
'WEALTHBOX_CLIENT_SECRET'
)
return {
tokenEndpoint: 'https://app.crmworkspace.com/oauth/token',
@@ -1494,9 +1530,10 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig {
}
}
case 'webflow': {
const { clientId, clientSecret } = getCredentials(
env.WEBFLOW_CLIENT_ID,
env.WEBFLOW_CLIENT_SECRET
const { clientId, clientSecret } = getConfiguredClientCredentials(
'webflow',
'WEBFLOW_CLIENT_ID',
'WEBFLOW_CLIENT_SECRET'
)
return {
tokenEndpoint: 'https://api.webflow.com/oauth/access_token',
@@ -1507,9 +1544,10 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig {
}
}
case 'asana': {
const { clientId, clientSecret } = getCredentials(
env.ASANA_CLIENT_ID,
env.ASANA_CLIENT_SECRET
const { clientId, clientSecret } = getConfiguredClientCredentials(
'asana',
'ASANA_CLIENT_ID',
'ASANA_CLIENT_SECRET'
)
return {
tokenEndpoint: 'https://app.asana.com/-/oauth_token',
@@ -1520,9 +1558,10 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig {
}
}
case 'pipedrive': {
const { clientId, clientSecret } = getCredentials(
env.PIPEDRIVE_CLIENT_ID,
env.PIPEDRIVE_CLIENT_SECRET
const { clientId, clientSecret } = getConfiguredClientCredentials(
'pipedrive',
'PIPEDRIVE_CLIENT_ID',
'PIPEDRIVE_CLIENT_SECRET'
)
return {
tokenEndpoint: 'https://oauth.pipedrive.com/oauth/token',
@@ -1533,9 +1572,10 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig {
}
}
case 'hubspot': {
const { clientId, clientSecret } = getCredentials(
env.HUBSPOT_CLIENT_ID,
env.HUBSPOT_CLIENT_SECRET
const { clientId, clientSecret } = getConfiguredClientCredentials(
'hubspot',
'HUBSPOT_CLIENT_ID',
'HUBSPOT_CLIENT_SECRET'
)
return {
tokenEndpoint: 'https://api.hubapi.com/oauth/v1/token',
@@ -1546,9 +1586,10 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig {
}
}
case 'linkedin': {
const { clientId, clientSecret } = getCredentials(
env.LINKEDIN_CLIENT_ID,
env.LINKEDIN_CLIENT_SECRET
const { clientId, clientSecret } = getConfiguredClientCredentials(
'linkedin',
'LINKEDIN_CLIENT_ID',
'LINKEDIN_CLIENT_SECRET'
)
return {
tokenEndpoint: 'https://www.linkedin.com/oauth/v2/accessToken',
@@ -1559,9 +1600,10 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig {
}
}
case 'instagram': {
const { clientId, clientSecret } = getCredentials(
env.INSTAGRAM_CLIENT_ID,
env.INSTAGRAM_CLIENT_SECRET
const { clientId, clientSecret } = getConfiguredClientCredentials(
'instagram',
'INSTAGRAM_CLIENT_ID',
'INSTAGRAM_CLIENT_SECRET'
)
return {
tokenEndpoint: 'https://graph.instagram.com/refresh_access_token',
@@ -1573,9 +1615,10 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig {
}
}
case 'salesforce': {
const { clientId, clientSecret } = getCredentials(
env.SALESFORCE_CLIENT_ID,
env.SALESFORCE_CLIENT_SECRET
const { clientId, clientSecret } = getConfiguredClientCredentials(
'salesforce',
'SALESFORCE_CLIENT_ID',
'SALESFORCE_CLIENT_SECRET'
)
return {
tokenEndpoint: 'https://login.salesforce.com/services/oauth2/token',
@@ -1588,9 +1631,10 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig {
case 'shopify': {
// Shopify access tokens don't expire and don't support refresh tokens
// This configuration is provided for completeness but won't be used for token refresh
const { clientId, clientSecret } = getCredentials(
env.SHOPIFY_CLIENT_ID,
env.SHOPIFY_CLIENT_SECRET
const { clientId, clientSecret } = getConfiguredClientCredentials(
'shopify',
'SHOPIFY_CLIENT_ID',
'SHOPIFY_CLIENT_SECRET'
)
return {
tokenEndpoint: 'https://accounts.shopify.com/oauth/token',
@@ -1601,7 +1645,11 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig {
}
}
case 'zoom': {
const { clientId, clientSecret } = getCredentials(env.ZOOM_CLIENT_ID, env.ZOOM_CLIENT_SECRET)
const { clientId, clientSecret } = getConfiguredClientCredentials(
'zoom',
'ZOOM_CLIENT_ID',
'ZOOM_CLIENT_SECRET'
)
return {
tokenEndpoint: 'https://zoom.us/oauth/token',
clientId,
@@ -1613,9 +1661,10 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig {
case 'wordpress': {
// WordPress.com does NOT support refresh tokens
// Users will need to re-authorize when tokens expire (~2 weeks)
const { clientId, clientSecret } = getCredentials(
env.WORDPRESS_CLIENT_ID,
env.WORDPRESS_CLIENT_SECRET
const { clientId, clientSecret } = getConfiguredClientCredentials(
'wordpress',
'WORDPRESS_CLIENT_ID',
'WORDPRESS_CLIENT_SECRET'
)
return {
tokenEndpoint: 'https://public-api.wordpress.com/oauth2/token',
@@ -1626,9 +1675,10 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig {
}
}
case 'spotify': {
const { clientId, clientSecret } = getCredentials(
env.SPOTIFY_CLIENT_ID,
env.SPOTIFY_CLIENT_SECRET
const { clientId, clientSecret } = getConfiguredClientCredentials(
'spotify',
'SPOTIFY_CLIENT_ID',
'SPOTIFY_CLIENT_SECRET'
)
return {
tokenEndpoint: 'https://accounts.spotify.com/api/token',
@@ -1639,9 +1689,10 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig {
}
}
case 'monday': {
const { clientId, clientSecret } = getCredentials(
env.MONDAY_CLIENT_ID,
env.MONDAY_CLIENT_SECRET
const { clientId, clientSecret } = getConfiguredClientCredentials(
'monday',
'MONDAY_CLIENT_ID',
'MONDAY_CLIENT_SECRET'
)
return {
tokenEndpoint: 'https://auth.monday.com/oauth2/token',
@@ -1657,7 +1708,11 @@ function getProviderAuthConfig(provider: string): ProviderAuthConfig {
// The refresh must target the accounts server; a US/multi-DC-enabled client
// uses accounts.zoho.com. Data residency for API calls is honored separately
// via the persisted Desk base URL derived from the token response api_domain.
const { clientId, clientSecret } = getCredentials(env.ZOHO_CLIENT_ID, env.ZOHO_CLIENT_SECRET)
const { clientId, clientSecret } = getConfiguredClientCredentials(
'zoho-desk',
'ZOHO_CLIENT_ID',
'ZOHO_CLIENT_SECRET'
)
return {
tokenEndpoint: 'https://accounts.zoho.com/oauth/v2/token',
clientId,
+3
View File
@@ -168,10 +168,13 @@ export interface OAuthServiceConfig {
* Service metadata without React components - safe for server-side use
*/
export interface OAuthServiceMetadata {
serviceId: string
providerId: string
serviceAccountProviderId?: string
name: string
description: string
baseProvider: string
authType: OAuthAuthType
}
export interface Credential {
+19
View File
@@ -25,14 +25,18 @@ describe('getAllOAuthServices', () => {
services.forEach((service) => {
expect(service).toHaveProperty('providerId')
expect(service).toHaveProperty('serviceId')
expect(service).toHaveProperty('name')
expect(service).toHaveProperty('description')
expect(service).toHaveProperty('baseProvider')
expect(service).toHaveProperty('authType')
expect(typeof service.providerId).toBe('string')
expect(typeof service.serviceId).toBe('string')
expect(typeof service.name).toBe('string')
expect(typeof service.description).toBe('string')
expect(typeof service.baseProvider).toBe('string')
expect(['oauth', 'service_account']).toContain(service.authType)
})
})
@@ -87,9 +91,24 @@ describe('getAllOAuthServices', () => {
services.forEach((service) => {
const metadata: OAuthServiceMetadata = service
expect(metadata.providerId).toBeDefined()
expect(metadata.serviceId).toBeDefined()
expect(metadata.name).toBeDefined()
expect(metadata.description).toBeDefined()
expect(metadata.baseProvider).toBeDefined()
expect(metadata.authType).toBeDefined()
})
})
it.concurrent('preserves service-account auth metadata', () => {
const services = getAllOAuthServices()
expect(services.find((service) => service.providerId === 'claude-platform')).toMatchObject({
serviceId: 'claude-platform',
authType: 'service_account',
})
expect(services.find((service) => service.providerId === 'google-email')).toMatchObject({
serviceId: 'gmail',
authType: 'oauth',
})
})
})
+4 -1
View File
@@ -473,12 +473,15 @@ export function getAllOAuthServices(): OAuthServiceMetadata[] {
const services: OAuthServiceMetadata[] = []
for (const [baseProviderId, provider] of Object.entries(OAUTH_PROVIDERS)) {
for (const service of Object.values(provider.services)) {
for (const [serviceId, service] of Object.entries(provider.services)) {
services.push({
serviceId,
providerId: service.providerId,
serviceAccountProviderId: service.serviceAccountProviderId,
name: service.name,
description: service.description,
baseProvider: baseProviderId,
authType: service.authType ?? 'oauth',
})
}
}
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist'
describe('intersectIntegrationAllowlists', () => {
it('uses the configured list when the other policy is unrestricted', () => {
expect(intersectIntegrationAllowlists(null, ['Slack'])).toEqual(['slack'])
expect(intersectIntegrationAllowlists(['Notion'], null)).toEqual(['notion'])
expect(intersectIntegrationAllowlists(null, null)).toBeNull()
})
it('keeps only integrations allowed by both policies', () => {
expect(intersectIntegrationAllowlists(['Slack', 'Notion'], ['notion', 'gmail'])).toEqual([
'notion',
])
})
it('preserves an explicit deny-all list', () => {
expect(intersectIntegrationAllowlists([], null)).toEqual([])
expect(intersectIntegrationAllowlists(['slack'], [])).toEqual([])
})
})
@@ -0,0 +1,17 @@
/**
* Intersects integration allowlists from independent policy layers.
* `null` means unrestricted, while an empty array denies every integration.
*/
export function intersectIntegrationAllowlists(
first: readonly string[] | null,
second: readonly string[] | null
): string[] | null {
const normalizedFirst = first?.map((integration) => integration.toLowerCase()) ?? null
const normalizedSecond = second?.map((integration) => integration.toLowerCase()) ?? null
if (normalizedFirst === null) return normalizedSecond
if (normalizedSecond === null) return normalizedFirst
const secondSet = new Set(normalizedSecond)
return normalizedFirst.filter((integration) => secondSet.has(integration))
}
+31 -2
View File
@@ -3,7 +3,14 @@
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('@/lib/core/config/env', () => ({ env: { REDIS_URL: undefined } }))
const { mockEnv } = vi.hoisted(() => ({
mockEnv: {
REDIS_URL: undefined as string | undefined,
REDIS_TLS_SERVERNAME: undefined as string | undefined,
},
}))
vi.mock('@/lib/core/config/env', () => ({ env: mockEnv }))
vi.mock('@/lib/core/config/redis', () => ({ getRedisClient: () => null }))
import {
@@ -32,7 +39,11 @@ function serializerFor(streamId: string, value: string) {
}
describe('event-log (memory fallback)', () => {
beforeEach(() => resetEventLogMemoryForTesting())
beforeEach(() => {
mockEnv.REDIS_URL = undefined
mockEnv.REDIS_TLS_SERVERNAME = undefined
resetEventLogMemoryForTesting()
})
it('assigns monotonically increasing event ids', async () => {
const first = await appendEvent(config, 's1', serializerFor('s1', 'a'))
@@ -83,4 +94,22 @@ describe('event-log (memory fallback)', () => {
const result = await readEventsSince<TestEntry>(config, 'missing', 5)
expect(result.status).toBe('pruned')
})
it('does not use memory when Redis is selected but its client is unavailable', async () => {
mockEnv.REDIS_URL = 'redis://localhost:6379'
await expect(appendEvent(config, 's1', serializerFor('s1', 'a'))).resolves.toBeNull()
await expect(readEventsSince<TestEntry>(config, 's1', 0)).resolves.toEqual({
status: 'unavailable',
error: 'Redis client unavailable',
})
})
it('fails fast instead of using memory for an invalid Redis configuration', async () => {
mockEnv.REDIS_URL = 'https://cache.example.com'
await expect(appendEvent(config, 's1', serializerFor('s1', 'a'))).rejects.toThrow(
/valid redis:\/\/ or rediss:\/\/ URL/
)
})
})
+2 -2
View File
@@ -16,7 +16,7 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
import { env } from '@/lib/core/config/env'
import { getConfiguredCacheProvider } from '@/lib/core/config/env-capabilities.server'
import { getRedisClient } from '@/lib/core/config/redis'
const logger = createLogger('EventLog')
@@ -111,7 +111,7 @@ function memoryKey(config: EventLogConfig, streamId: string) {
}
function canUseMemoryBuffer(): boolean {
return typeof window === 'undefined' && !env.REDIS_URL
return typeof window === 'undefined' && getConfiguredCacheProvider() === 'database'
}
function pruneExpiredMemoryStreams(now = Date.now()): void {

Some files were not shown because too many files have changed in this diff Show More