mirror of
https://github.com/simstudioai/sim.git
synced 2026-08-30 17:05:18 +08:00
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:
@@ -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'`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 })
|
||||
),
|
||||
})
|
||||
})
|
||||
|
||||
+58
-24
@@ -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}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
+9
-2
@@ -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>
|
||||
|
||||
+12
-4
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,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'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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' })
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' } })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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'>>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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/
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user