mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-06 10:48:12 +02:00
fix(connections): repair personal credentials and request write access (#14739)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agents use Apps through a gateway that checks identity, company access, and action policies. > - Personal pasted credentials can point to company secrets. Setup can show success while the gateway rejects every call. > - Several OAuth methods also omit the scopes needed for their supported write actions. > - This pull request gives setup, health checks, and invocation the same credential rules. Owners repair existing connections by reconnecting. > - New connections request reviewed permissions for their supported actions. Read-only choices remain available under Advanced. > - Agents can use the connections people give them, while existing consent, identity boundaries, and action restrictions remain enforced. ## Linked Issues or Issue Description Refs #14009 and #14008. This addresses the personal-credential defect. The separate GitHub organization-identity selection defect is outside this change. Related work: #13942 fixed part of new personal-key setup. #14200 independently fixes legacy personal reconnect and protects managed-agent profile credentials during removal. This PR covers that ownership invariant across key and secret-URL setup, reconnect, health, discovery, and invocation, and keeps owner reconnect as the repair path. #14059 tracks requested versus provider-asserted OAuth scopes; it remains separate work. I searched open PRs and issues for Zapier, Airtable scopes, connector writes, and personal credential failures. **What happened?** A Zapier secret URL saved through personal setup can become a company secret referenced by a user grant. Health checks bypass the gateway's ownership check, so the connection appears healthy but calls fail with `grant_credential_invalid`. Custom-header paths can also receive a duplicate `credentials.` prefix. Omitted OAuth scopes make write access depend on provider defaults. **Expected behavior** Personal invocation credentials belong to the selected user. Setup, health, and actual calls enforce the same rule. New connections request documented permissions for supported read and write actions. Existing tokens gain no permissions without provider consent. **Steps to reproduce** 1. Connect Zapier or a generic secret URL with the personal identity. 2. Allow an agent to use the connection and complete setup. 3. Invoke a tool through a run-scoped gateway. The legacy layout fails ownership validation despite successful setup. **Paperclip version or commit** The implementation started from `44736c9c7c67b7b646ead9d51721db10f5b83835` and was rebased onto master at `94e8dec56`. **Deployment mode** Built from source. Regression tests use isolated PostgreSQL fixtures and controlled MCP transports. ## What Changed - Share credential writing, ownership validation, and canonical paths across initial setup, resume, reconnect, rotation, health, discovery, and gateway calls. Keep OAuth client-registration secrets separate from invocation credentials. - Existing personal connections with company-scoped credentials require owner reconnect with a fresh key or secret URL. Reconnect creates a correctly owned value and updates the existing grant and declarations. There is no automatic ownership backfill or new startup hook. - Preserve PostgreSQL timestamp precision when reconnect checks whether a grant changed. Previously, converting the timestamp to a JavaScript Date could reject reconnect with a false concurrent-change error. - Protect credentials used by other grants, connections, bindings, managed-agent profiles, routine triggers, or secret proposals from connection removal. - Review all 117 tool methods, including 84 OAuth methods. Record explicit scopes or documented provider-default exceptions with official evidence. Add Airtable's seven scopes, Hugging Face repository/job scopes, and other documented MCP permissions. - Prefer available write-capable methods. Put explicit read-only choices under Advanced. Explain pasted-key permissions and offer reconnect for missing OAuth consent. Preserve existing grants, policies, Google availability gates, and curated scope allowlists. - Reconnect generic secret URLs and custom headers using their stored credential fields. Refresh the catalog after setup, correct reconnect feedback and error guidance, and let Cancel exit invalid setup while Save & exit retains draft-saving behavior. - Apply ownership checks to the new GitHub repository/skill connection picker. Align the permission audit with the Google scope reductions merged on master. - Add run-scoped gateway, ownership, owner-reconnect, OAuth URL, insufficient-scope, UI, and catalog-wide regression coverage. Update the connector playbook and permission audit. ## Verification Latest commit `97bc0b86e0eae0ec892e4ac44beff1a66164b20e` passes all CI/status gates (55 completed check runs, no failures or pending checks) and has a completed Greptile review at **5/5 with no outstanding findings**. GitHub reports the PR as mergeable/CLEAN. - **Embedded browser:** used the actual server and built UI from this worktree, a fresh isolated database, and local HTTP MCP fixtures. Completed personal bearer-key, secret-URL, and custom-header setup; reproduced the legacy ownership failure; reconnected through the owner’s form; and completed writes afterward. Read-back was verified for bearer-key and secret-URL connections. Public organization-wide setup appeared immediately in Browse without reload. Zapier URL validation/Cancel and Google’s enrollment gate were also exercised. - **Persistence and invocation:** verified user ownership, canonical `credentials.authorization` / `remote.url` / `headers.X-Api-Key` declarations, and unchanged connection/grant identity. The old company secrets retain their ownership. Separate HTTP calls through an actual run-scoped gateway session completed a write and read-back. - **Backend coverage:** the final gateway suite passes all 82 cases, including catalog Zapier and generic inline reconnect. It checks company/user isolation, canonical declarations, same-endpoint URL validation, fresh credentials, retained restrictions, and real gateway read/write execution using fixture transport. A timestamp with PostgreSQL microseconds covers the former false reconnect conflict. - **Local checks:** 368 catalog, gateway, repository, and UI tests passed before the final extra Zapier case; 49 GitHub skill access tests also passed. All three Apps browser regressions pass, including reconnect through the actual form and catalog visibility without reload. Full `pnpm -r typecheck`, `pnpm build`, server typecheck after the final patch, and token gates passed. Full tool-access service runs hit varying 15-second Google fixture timeouts; both affected cases and the updated reconnect assertion pass in isolation (3 tests). The complete test matrix passes in CI on this head. - **Verification limits:** no live provider account was available for Zapier/Airtable/OAuth consent or account-bound write proof. Public metadata and local fixtures do not establish provider consent. The original development database clone failed on a pre-existing missing `tool_connections_transport_check` constraint; browser acceptance used a fresh isolated database created by the normal CLI onboarding flow. ## Risks - Existing broken personal connections stay unusable until their owner reconnects. Health, discovery, and invocation return an actionable ownership error; startup does not rewrite credential ownership. - Scope changes affect new authorization requests. Providers may still require resource selection, account roles, paid plans, or app verification. Existing consent and action restrictions remain unchanged. - Shared credentials are retained rather than reassigned or revoked. Provider-default exceptions and unavailable live checks are documented in `doc/connections/CONNECTOR-PERMISSION-AUDIT.md`. - No new endpoint, database table, lockfile change, or CI workflow change is included. ## Model Used OpenAI Codex, based on GPT-6, with reasoning, code editing, shell execution, web research, and browser tools. The exact deployment model ID and context window were not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
cbd278dc03
commit
ad55d0a281
70 files changed
+3544
-704
No files matched your search
@@ -0,0 +1,160 @@
|
||||
# Tool connection permission audit — 2026-09-30
|
||||
|
||||
This review covers 117 tool methods (84 OAuth methods). The machine-readable
|
||||
[source of reviewed defaults](./tool-method-permission-reviews.json) lists each
|
||||
method's exact requested scopes, supported actions, restrictions, sources and
|
||||
verification limits. AI runtime authentication and chat/channel setup are
|
||||
separate contracts and were not changed.
|
||||
|
||||
## Shared credential failure
|
||||
|
||||
Zapier's secret URL exposed a shared ownership/resolution problem. The same
|
||||
invariants apply to personal pasted-key and custom-header methods, including
|
||||
Bitly, Cloudflare, Coda, Fireflies, GitHub PATs, Kernel, O'Reilly, PagerDuty,
|
||||
PostHog, Postman, Razorpay, Sanity, Similarweb, Stripe, Supabase and You.com.
|
||||
Generic MCP setup uses this path too, so “Connect your own” was not a reliable
|
||||
workaround. This is a code-path finding, not a claim that every provider was
|
||||
tested with a live account.
|
||||
|
||||
Personal invocation secrets now belong to the grant's user. Setup, health,
|
||||
discovery and the run gateway share ownership checks and canonical credential
|
||||
paths. Public unauthenticated URLs need no credential repair. OAuth client
|
||||
registration secrets remain company-owned and separate from invocation tokens.
|
||||
Existing connections with incorrectly owned credentials require the owner to
|
||||
reconnect with a fresh key or secret URL. There is no automatic ownership backfill.
|
||||
|
||||
## Changes
|
||||
|
||||
- Airtable now requests its seven documented record, schema, comment and
|
||||
workspace/base scopes, including writes.
|
||||
- Explicit scope sets were added for providers that advertise a scoped MCP
|
||||
resource, including Beehiiv, Netlify, Miro, Sentry, Supabase, Todoist and
|
||||
TickTick. Read and write remain subject to provider roles and resource selection.
|
||||
- Hugging Face adds repository read/contribution and jobs to `read-mcp`.
|
||||
Optional tools still need enabling in HF's MCP settings.
|
||||
- Slack's reviewed set now covers the documented message, channel, reaction,
|
||||
canvas, file and list actions. It replaces the obsolete generic search scope
|
||||
with the documented search scopes. The fixed app/approval gate remains.
|
||||
- Google write/draft methods take priority over managed read-only methods when
|
||||
available. Existing Google profiles, preview verification and availability
|
||||
gates remain. People and Workspace Search stay read-only.
|
||||
- The hosted Fireflies server also exposes meeting sharing, renaming, moving and
|
||||
soundbite creation. Its identity scopes are retained; meeting ownership and
|
||||
team roles decide which mutations succeed.
|
||||
- Provider read-only choices stay available under Advanced. Existing OAuth
|
||||
tokens and action restrictions are untouched. Permissions offers reconnect
|
||||
when provider consent needs changing.
|
||||
|
||||
## Provider-default exceptions
|
||||
|
||||
These are deliberate exceptions to supplying `scopesHint`, not claims that an
|
||||
old grant can write. Several servers put resource/permission selection in their
|
||||
own consent screen; Box uses registered app scopes, and managed GitHub uses
|
||||
installation permissions. An authorization server's general scope catalog is
|
||||
not a safe substitute for the MCP permission contract.
|
||||
|
||||
| Provider / method | Reason and limits |
|
||||
| --- | --- |
|
||||
| bitly / `mcp-oauth` | The official MCP quickstart uses browser authorization without client-selected scopes; neither protected-resource nor authorization-server metadata advertises a scope list. Bitly account permissions govern link actions. [Evidence](https://dev.bitly.com/bitly-mcp/overview/quickstart/) |
|
||||
| box / `mcp-own-oauth` | Box requires scopes (including Manage AI) on the registered Box app in the Admin Console. Its MCP authorization metadata has no request-scope list; consent and the app registration control file operations. [Evidence](https://support.box.com/hc/en-us/articles/43847256139923-Managing-Box-MCP-Servers) |
|
||||
| egnyte / `mcp-oauth` | The hosted authorization server selects the Egnyte tenant/account permissions. Neither MCP nor authorization-server metadata advertises request scopes. Documentation rendered no readable body during this review; tenant-bound write proof remains required. [Evidence](https://developers.egnyte.com/docs/Remote_MCP_Server) |
|
||||
| github / `managed` | Managed github.code uses a GitHub App installation and selected repositories. Installation permissions, not OAuth scope strings, grant code and pull-request writes; retain the managed profile. [Evidence](https://api.githubcopilot.com/.well-known/oauth-protected-resource/mcp/) |
|
||||
| make / `mcp-oauth` | Make documents selecting scopes and scenarios during its hosted connection flow. Management tools require a paid plan. Do not replace that resource selection with invented MCP client scopes. [Evidence](https://developers.make.com/mcp-server) |
|
||||
| oauth-generic / `oauth` | Operator-defined OAuth endpoints have no provider-specific reviewed scope list. Request only the scopes supplied by the operator; this template is not a curated provider allowlist. [Evidence](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization) |
|
||||
| planetscale / `mcp-oauth` | PlanetScale documents choosing organizations, databases and read/write permission at authorization. Its general authorization server advertises unrelated organization administration too; retain the provider consent selection, and the separate insights-only endpoint. [Evidence](https://planetscale.com/docs/connect/mcp) |
|
||||
| planetscale / `mcp-insights-only` | PlanetScale documents choosing organizations, databases and read/write permission at authorization. Its general authorization server advertises unrelated organization administration too; retain the provider consent selection, and the separate insights-only endpoint. [Evidence](https://planetscale.com/docs/connect/mcp) |
|
||||
| posthog / `mcp-oauth` | PostHog documents the no-scope MCP setup as read/write, with optional readonly and feature filters. The authorization server advertises account administration unrelated to many tools; retain its MCP consent defaults and the existing explicit filters. [Evidence](https://posthog.com/docs/model-context-protocol) |
|
||||
| postman / `mcp-oauth-minimal` | The official remote MCP setup uses browser sign-in and endpoint-selected minimal/code/full tool catalogs. Its protected-resource and authorization-server metadata publish no scope set; account/workspace rights govern writes. [Evidence](https://learning.postman.com/latest-v-12/docs/reference/postman-api/postman-mcp-server/postman-mcp-remote-server) |
|
||||
| postman / `mcp-oauth-code` | The official remote MCP setup uses browser sign-in and endpoint-selected minimal/code/full tool catalogs. Its protected-resource and authorization-server metadata publish no scope set; account/workspace rights govern writes. [Evidence](https://learning.postman.com/latest-v-12/docs/reference/postman-api/postman-mcp-server/postman-mcp-remote-server) |
|
||||
| postman / `mcp-oauth-full` | The official remote MCP setup uses browser sign-in and endpoint-selected minimal/code/full tool catalogs. Its protected-resource and authorization-server metadata publish no scope set; account/workspace rights govern writes. [Evidence](https://learning.postman.com/latest-v-12/docs/reference/postman-api/postman-mcp-server/postman-mcp-remote-server) |
|
||||
| razorpay / `mcp-oauth` | Neither protected-resource nor authorization-server metadata advertises scopes. Keep hosted provider consent; the previous official OAuth documentation URL returned 404 during review. Account-bound writes need live confirmation. [Evidence](https://razorpay.com/docs/mcp-server/oauth/) |
|
||||
| ticket-tailor / `mcp-oauth` | Hosted authorization controls box-office API-key permissions; do not treat OAuth sign-in as increasing the underlying key permissions. This method requires account-bound proof. [Evidence](https://developers.tickettailor.com/docs/mcp/authentication/) |
|
||||
| webflow / `mcp-oauth` | The official MCP setup installs the Bridge App and asks users to authorize sites. Neither protected-resource nor authorization-server metadata publishes a request-scope list; the installed app and site selection govern writes. [Evidence](https://developers.webflow.com/mcp/reference/getting-started) |
|
||||
|
||||
## Read-only and provider-controlled methods
|
||||
|
||||
Candid, Context7, O'Reilly, Similarweb and You.com expose retrieval/research
|
||||
capabilities rather than document/account mutation. PlanetScale's insights-only
|
||||
endpoint deliberately excludes query execution. Xero's current published MCP
|
||||
scope set includes invoice/report reads and settings access, without invoice
|
||||
write permission. Do not widen these using unrelated general API scopes.
|
||||
|
||||
Scopes such as `openid`, `global`, `mcp:all` or `workspace:member` do not have to
|
||||
contain the word “write” to authorize work. The provider's MCP tool contract,
|
||||
role and consent selection determine their meaning. In particular, Cloudflare,
|
||||
Vercel, Wix, Kernel and Supermemory retain their provider-side permission gates.
|
||||
|
||||
## Evidence and verification boundaries
|
||||
|
||||
Official docs and unauthenticated protected-resource/authorization metadata were
|
||||
retrieved on 2026-09-30. The review records source URLs per method. Some pages
|
||||
(especially Egnyte and Razorpay's old OAuth URL) were unavailable or unreadable;
|
||||
those entries explicitly retain that limitation. Embat publishes identity
|
||||
metadata but no verified write contract. No account-bound write guarantee is
|
||||
made for these entries.
|
||||
|
||||
No provider account was used for live read/write verification in this task.
|
||||
In particular, public scope metadata is **not** proof that an existing token has
|
||||
those permissions or that an account has the required paid plan, resource ACLs,
|
||||
admin approval, or Google preview enrollment. Use test accounts/resources for
|
||||
that proof before claiming provider-level acceptance.
|
||||
|
||||
Backend regressions execute catalog Zapier, generic secret URLs, personal
|
||||
bearer/custom headers, organization credentials and public URLs through the
|
||||
run-scoped gateway using a fixture transport. They assert canonical persisted
|
||||
ownership/declarations and run read and write calls. Reconnect regressions cover
|
||||
replacing legacy company credentials with user-owned values and declarations.
|
||||
OAuth fixtures assert exact authorization scopes and rejection of an unrelated
|
||||
advertised admin scope; they do not contact provider accounts.
|
||||
|
||||
The managed worktree has a separate instance configuration and database path.
|
||||
Local CLI provisioning was attempted with both minimal and full seed modes;
|
||||
both failed while applying the copied database's migrations because
|
||||
`tool_connections_transport_check` was missing. The development instance was
|
||||
not started from that clone. Clean fixture databases were used for the backend and Apps browser
|
||||
checks; their success does not establish successful seeding of that local copy.
|
||||
|
||||
### Embedded-browser acceptance
|
||||
|
||||
On 2026-09-30, a hands-on walkthrough used the PR checkout's built UI and actual
|
||||
server, a fresh isolated database created through CLI onboarding, and a local
|
||||
HTTP MCP fixture. Starting from the sidebar's Connectors page, the operator
|
||||
selected “Just me,” entered a bearer key, and created and read back a disposable
|
||||
widget through the Permissions screen's agent test controls.
|
||||
|
||||
After seeding the legacy ownership mismatch in that disposable connection,
|
||||
invocation and catalog refresh rejected it with `grant_credential_invalid`.
|
||||
The catalog showed “Needs attention” and offered Reconnect. Replacing the key
|
||||
through that UI preserved the connection and personal grant, created a fresh
|
||||
user-owned secret and canonical declaration, and restored writes. The legacy
|
||||
company secret retained its ownership. Separate HTTP calls through a session
|
||||
bound to a fixture agent run also completed a write and read-back.
|
||||
|
||||
The walkthrough exposed and verified fixes for a false “Still not working”
|
||||
message after successful reconnect, incorrect action-input advice for ownership
|
||||
errors, and Cancel attempting to save an invalid Zapier URL. The browser
|
||||
reconnect regression now performs the replacement through the form and checks
|
||||
that the stale warning disappears.
|
||||
|
||||
A second personal connection used a secret URL. After reproducing its legacy
|
||||
ownership failure, the browser could replace the URL and create/read back a
|
||||
widget. Generic reconnect now derives URL/header fields from the stored
|
||||
credential placement instead of assuming a bearer key, and rejects replacement
|
||||
URLs for a different public endpoint. A new public, organization-wide connection
|
||||
also appeared immediately when returning to Browse, without a page reload.
|
||||
The walkthrough fixed setup cache invalidation and a cramped reconnect banner.
|
||||
|
||||
Zapier's URL validation and cancellation were exercised, but a live Zapier
|
||||
connection was not completed. Gmail stopped at instance enrollment. Neither
|
||||
journey establishes provider-account consent or live provider read/write proof.
|
||||
|
||||
### Automated verification
|
||||
|
||||
- Run `pnpm -r typecheck`, `pnpm build`, and `pnpm check:token-gates`.
|
||||
- The affected Apps browser suites passed 10 tests, with one existing restart
|
||||
case skipped. These used clean, isolated fixture databases.
|
||||
- Run `pnpm test:run` for the full suite. Current-head CI and local results are
|
||||
recorded in the pull request; fixture coverage is distinct from provider proof.
|
||||
- New gateway fixtures execute both read and write calls through real run-scoped
|
||||
gateway sessions. They also verify that invalid ownership requires reconnect
|
||||
and that the owner's fresh credentials restore access without changing identity.
|
||||
@@ -61,6 +61,59 @@ execution. Extend the provider's existing catalog entry with typed AI methods;
|
||||
reuse the existing login controllers. See [AI Connections](./AI-CONNECTIONS.md)
|
||||
for compatibility, personal defaults, resolver isolation, and legacy adoption.
|
||||
|
||||
## Read/write defaults and credential ownership
|
||||
|
||||
New tool connections request the provider-documented permissions needed for their
|
||||
supported read **and write** actions. Prefer an available write/draft capability
|
||||
before a managed read-only method. Read-only capability choices and provider
|
||||
read-only switches belong under **Advanced**. Preserve an explicit method on
|
||||
resume/reconnect. Keep the Access → Connect flow and manage action restrictions
|
||||
on Permissions; changing OAuth configuration never changes existing consent or
|
||||
turns an Off/Ask-first action into Allowed.
|
||||
|
||||
Every tool method must have a review in
|
||||
[`tool-method-permission-reviews.json`](./tool-method-permission-reviews.json).
|
||||
It records requested scopes, supported actions, provider restrictions, official
|
||||
evidence, and live-proof status. The ingestion script uses its explicit scope
|
||||
lists as `scopesHint`; the catalog regression rejects missing reviews, drift,
|
||||
and undocumented omitted OAuth scopes. An omission requires a provider-default
|
||||
exception explaining how consent/registration grants access. Never automatically
|
||||
request all scopes advertised by an authorization server. See the
|
||||
[permission audit](./CONNECTOR-PERMISSION-AUDIT.md) for review findings and limits.
|
||||
API-key fields must explain required provider permissions; Paperclip cannot
|
||||
increase an already-issued key's permissions. Reconnect with fresh consent/key
|
||||
when access is insufficient. A provider's explicit `insufficient_scope` response
|
||||
becomes an actionable `oauth_insufficient_scope` error; provider response text
|
||||
and credentials are not echoed.
|
||||
|
||||
All invocation credential writes use `writeConnectionCredential` (setup,
|
||||
replacement, reconnect, OAuth completion and rotation). A personal grant requires
|
||||
a **user-scoped** secret owned by its subject and a user-secret definition and
|
||||
declaration for the connection. Organization/dedicated-agent credentials use
|
||||
company secrets and bindings. OAuth **client-registration** secrets remain
|
||||
company-owned and are resolved separately from action credentials. Declaration
|
||||
paths are canonical (`credentials.authorization`, `headers.X-Api-Key`,
|
||||
`remote.url`, `oauth.access_token`), never double-prefixed. Health, discovery,
|
||||
board tests, invocation, and version tracking must use the selected grant's
|
||||
refs and the same ownership checks. A personal resolver must not fall back to a
|
||||
company credential. Ownership mismatch is `grant_credential_invalid` and tells
|
||||
the owner to reconnect.
|
||||
|
||||
Existing personal connections with company-scoped invocation credentials require
|
||||
their owner to reconnect and enter a fresh key or secret URL. Reconnect creates
|
||||
a user-owned credential and updates the grant and declarations while preserving
|
||||
the connection identity and action policies. Credential ownership is never
|
||||
automatically reassigned at startup. Health, discovery and invocation reject an
|
||||
invalid ownership layout with an actionable reconnect error.
|
||||
|
||||
Verification must assert stored ownership and declarations, then execute a read
|
||||
and a write through a real run-scoped gateway. Cover generic and curated URL
|
||||
credentials, bearer/custom headers, shared identities, public endpoints, rotation,
|
||||
removal, failed-setup cleanup, owner reconnect of legacy credentials, another user,
|
||||
and another company. Fixture-backed MCP calls prove Paperclip behavior; they do
|
||||
not prove provider consent or account entitlements. Record account-bound live
|
||||
read/write proof separately and never describe metadata discovery as live proof.
|
||||
|
||||
## Contents
|
||||
|
||||
- [Mental model and support matrix](#mental-model-five-independent-axes)
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -592,6 +592,7 @@ describe("AppDefinition catalog", () => {
|
||||
expect(notion?.redirectConstraints).toBe("https-or-loopback-http");
|
||||
expect(notion?.methods[0]?.defaults).toEqual({
|
||||
serverUrl: "https://mcp.notion.com/mcp",
|
||||
scopesHint: ["default"],
|
||||
});
|
||||
});
|
||||
it("preserves required Linear OAuth scopes", () =>
|
||||
@@ -599,11 +600,11 @@ describe("AppDefinition catalog", () => {
|
||||
APP_DEFINITIONS.find((app) => app.slug === "linear")?.methods[0]?.defaults
|
||||
?.scopesHint,
|
||||
).toEqual(["read", "write"]));
|
||||
it("requests only Hugging Face's MCP read scope", () =>
|
||||
it("requests Hugging Face MCP read, contribution and job scopes", () =>
|
||||
expect(
|
||||
APP_DEFINITIONS.find((app) => app.slug === "hugging-face")?.methods[0]
|
||||
?.defaults?.scopesHint,
|
||||
).toEqual(["read-mcp"]));
|
||||
).toEqual(["read-mcp", "read-repos", "contribute-repos", "jobs"]));
|
||||
it("defaults every new connection action to allowed", () => {
|
||||
for (const app of APP_DEFINITIONS)
|
||||
for (const method of app.methods)
|
||||
@@ -638,7 +639,7 @@ describe("AppDefinition catalog", () => {
|
||||
].includes(candidate.key),
|
||||
),
|
||||
)?.key,
|
||||
).toBe("paperclip-read");
|
||||
).toBe("customer-draft-oauth");
|
||||
expect(
|
||||
getRecommendedConnectionMethod(
|
||||
gmail.methods.filter(
|
||||
@@ -1006,3 +1007,39 @@ describe("Railway provider", () => {
|
||||
expect(JSON.stringify(app.methods)).toContain("Live Railway qualification is pending");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("tool method permission review", () => {
|
||||
const audit = JSON.parse(fs.readFileSync(new URL("../../../doc/connections/tool-method-permission-reviews.json", import.meta.url), "utf8")) as {
|
||||
methods: { app: string; method: string; auth: string; policy: string; requestedScopes: string[]; providerDefaultReason?: string; supportedActions: string; evidence: string[]; reviewedAt: string }[];
|
||||
};
|
||||
const methods = APP_DEFINITIONS.flatMap((app) => app.methods
|
||||
.filter((method) => method.purpose !== "channel" && method.purpose !== "ai")
|
||||
.map((method) => ({ app, method })));
|
||||
it("requires an explicit review for every tool method, including documented scope omissions", () => {
|
||||
expect(new Set(audit.methods.map((review) => `${review.app}/${review.method}`)).size).toBe(audit.methods.length);
|
||||
expect(audit.methods).toHaveLength(methods.length);
|
||||
for (const { app, method } of methods) {
|
||||
const review = audit.methods.find((entry) => entry.app === app.slug && entry.method === method.key);
|
||||
expect(review, `${app.slug}/${method.key}`).toBeDefined();
|
||||
expect(review!.auth).toBe(method.auth);
|
||||
expect(review!.supportedActions.length).toBeGreaterThan(15);
|
||||
expect(review!.evidence.length).toBeGreaterThan(0);
|
||||
if (method.auth === "oauth") {
|
||||
expect(method.defaults?.scopesHint ?? []).toEqual(review!.requestedScopes);
|
||||
if (!review!.requestedScopes.length) {
|
||||
expect(review!.policy).toBe("provider-default");
|
||||
expect(review!.providerDefaultReason!.length).toBeGreaterThan(30);
|
||||
} else expect(review!.policy).toBe("explicit");
|
||||
}
|
||||
}
|
||||
});
|
||||
it("requests Airtable record, schema and comment writes, and Hugging Face repository/job actions", () => {
|
||||
expect(APP_DEFINITIONS.find((app) => app.slug === "airtable")!.methods[0]!.defaults!.scopesHint).toEqual([
|
||||
"data.records:read", "data.records:write", "schema.bases:read", "schema.bases:write",
|
||||
"data.recordComments:read", "data.recordComments:write", "workspacesAndBases:read",
|
||||
]);
|
||||
expect(APP_DEFINITIONS.find((app) => app.slug === "hugging-face")!.methods[0]!.defaults!.scopesHint)
|
||||
.toEqual(["read-mcp", "read-repos", "contribute-repos", "jobs"]);
|
||||
});
|
||||
});
|
||||
@@ -140,13 +140,11 @@ export function getRecommendedConnectionMethod(
|
||||
|| method.oauthStrategy === "paperclip_id_connector"
|
||||
);
|
||||
|
||||
// When a managed pilot advertises only read access, defaulting to a
|
||||
// customer-owned write method would turn the available one-click path into
|
||||
// an OAuth client setup form. Capability-specific callers pass only the
|
||||
// selected group, so explicit write/draft choices keep their own fallback.
|
||||
// Prefer the permissions needed for agent work, then the simplest sign-in.
|
||||
// Explicit read-only selections pass their own capability group here.
|
||||
return recommendedCapability(managedMethods)
|
||||
?? managedMethods[0]
|
||||
?? recommendedCapability(methods)
|
||||
?? managedMethods[0]
|
||||
?? methods[0]
|
||||
?? null;
|
||||
}
|
||||
@@ -222,6 +220,13 @@ export function credentialConfigPath(field: FieldDef, method?: ConnectionMethodD
|
||||
return `credentials.${field.key}`;
|
||||
}
|
||||
|
||||
/** Older credential references used bare names; current references use paths. */
|
||||
export function connectionCredentialConfigPath(ref: { name: string }): string {
|
||||
return /^(credentials|headers|oauth|remote)\./.test(ref.name)
|
||||
? ref.name
|
||||
: `credentials.${ref.name}`;
|
||||
}
|
||||
|
||||
export function resolveConnectionMethodServerUrl(
|
||||
method: ConnectionMethodDef,
|
||||
configValues: Record<string, string | boolean>,
|
||||
|
||||
@@ -25,7 +25,16 @@
|
||||
],
|
||||
"whenToUse": "Use browser sign-in for the provider-hosted MCP server.",
|
||||
"defaults": {
|
||||
"serverUrl": "https://mcp.airtable.com/mcp"
|
||||
"serverUrl": "https://mcp.airtable.com/mcp",
|
||||
"scopesHint": [
|
||||
"data.records:read",
|
||||
"data.records:write",
|
||||
"schema.bases:read",
|
||||
"schema.bases:write",
|
||||
"data.recordComments:read",
|
||||
"data.recordComments:write",
|
||||
"workspacesAndBases:read"
|
||||
]
|
||||
},
|
||||
"guidanceMd": "Connect Airtable in the browser. An Airtable account; enterprise administrators may need to allowlist the client.",
|
||||
"riskTier": "S3",
|
||||
|
||||
@@ -30,7 +30,8 @@
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"placeholder": "Paste the API key",
|
||||
"secret": true
|
||||
"secret": true,
|
||||
"helperMd": "Actions depend on the operator-supplied API and key; grant read/write on the required resources at the provider. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions."
|
||||
}
|
||||
],
|
||||
"keyPlacement": {
|
||||
|
||||
@@ -25,7 +25,10 @@
|
||||
],
|
||||
"whenToUse": "Register an OAuth app with Asana, then enter its client ID and secret.",
|
||||
"defaults": {
|
||||
"serverUrl": "https://mcp.asana.com/v2/mcp"
|
||||
"serverUrl": "https://mcp.asana.com/v2/mcp",
|
||||
"scopesHint": [
|
||||
"default"
|
||||
]
|
||||
},
|
||||
"guidanceMd": "Connect Asana in the browser. Create an Asana MCP OAuth app and register Paperclip's callback URI; DCR is not supported.",
|
||||
"riskTier": "S3",
|
||||
|
||||
@@ -25,7 +25,11 @@
|
||||
],
|
||||
"whenToUse": "Use browser sign-in for the provider-hosted MCP server.",
|
||||
"defaults": {
|
||||
"serverUrl": "https://mcp.beehiiv.com/mcp"
|
||||
"serverUrl": "https://mcp.beehiiv.com/mcp",
|
||||
"scopesHint": [
|
||||
"read",
|
||||
"write"
|
||||
]
|
||||
},
|
||||
"guidanceMd": "Connect beehiiv in the browser. A beehiiv account; the subscription plan controls available write capabilities.",
|
||||
"riskTier": "S3",
|
||||
|
||||
@@ -58,7 +58,8 @@
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"placeholder": "Paste your Bitly API token",
|
||||
"secret": true
|
||||
"secret": true,
|
||||
"helperMd": "Create/manage links in authorized groups. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions."
|
||||
}
|
||||
],
|
||||
"keyPlacement": {
|
||||
|
||||
@@ -26,7 +26,29 @@
|
||||
],
|
||||
"whenToUse": "Use browser sign-in for the provider-hosted MCP server.",
|
||||
"defaults": {
|
||||
"serverUrl": "https://api.brex.com/mcp"
|
||||
"serverUrl": "https://api.brex.com/mcp",
|
||||
"scopesHint": [
|
||||
"openid",
|
||||
"offline_access",
|
||||
"email",
|
||||
"users.readonly",
|
||||
"departments.readonly",
|
||||
"locations.readonly",
|
||||
"titles.readonly",
|
||||
"legal_entities.readonly",
|
||||
"cards.readonly",
|
||||
"cards",
|
||||
"companies.readonly",
|
||||
"budgets.readonly",
|
||||
"travel.trips.readonly",
|
||||
"expenses.card.readonly",
|
||||
"expenses.card",
|
||||
"expenses.bill",
|
||||
"accounts.cash.readonly",
|
||||
"vendors.readonly",
|
||||
"accounting.integration.read",
|
||||
"accounting.record.read"
|
||||
]
|
||||
},
|
||||
"guidanceMd": "Connect Brex in the browser. Brex early access and an administrator enabling the integration; financial actions require explicit approval.",
|
||||
"riskTier": "S4",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"required": true,
|
||||
"placeholder": "bu_…",
|
||||
"secret": true,
|
||||
"helperMd": "Open Browser Use → Settings → API keys. Create a key for the project agents should use."
|
||||
"helperMd": "Create/stop browser sessions and run browser tasks under the API key. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions."
|
||||
}
|
||||
],
|
||||
"keyPlacement": {
|
||||
|
||||
@@ -25,7 +25,12 @@
|
||||
],
|
||||
"whenToUse": "Use browser sign-in for the provider-hosted MCP server.",
|
||||
"defaults": {
|
||||
"serverUrl": "https://mcp.candid.org/mcp"
|
||||
"serverUrl": "https://mcp.candid.org/mcp",
|
||||
"scopesHint": [
|
||||
"openid",
|
||||
"profile",
|
||||
"email"
|
||||
]
|
||||
},
|
||||
"guidanceMd": "Connect Candid in the browser. A Candid account with access to the MCP connector.",
|
||||
"riskTier": "S2",
|
||||
|
||||
@@ -26,7 +26,14 @@
|
||||
],
|
||||
"whenToUse": "Use browser sign-in for the provider-hosted MCP server.",
|
||||
"defaults": {
|
||||
"serverUrl": "https://mcp.clickhouse.cloud/clickstack"
|
||||
"serverUrl": "https://mcp.clickhouse.cloud/clickstack",
|
||||
"scopesHint": [
|
||||
"mcp:access",
|
||||
"clickstack:access",
|
||||
"openid",
|
||||
"profile",
|
||||
"email"
|
||||
]
|
||||
},
|
||||
"guidanceMd": "Connect ClickHouse in the browser. A ClickHouse Cloud ClickStack service and its service ID.",
|
||||
"riskTier": "S4",
|
||||
|
||||
@@ -25,7 +25,11 @@
|
||||
],
|
||||
"whenToUse": "Use browser sign-in for the provider-hosted MCP server.",
|
||||
"defaults": {
|
||||
"serverUrl": "https://mcp.cloudflare.com/mcp"
|
||||
"serverUrl": "https://mcp.cloudflare.com/mcp",
|
||||
"scopesHint": [
|
||||
"user:read",
|
||||
"account:read"
|
||||
]
|
||||
},
|
||||
"guidanceMd": "Connect Cloudflare in the browser. A Cloudflare account with access to the resources being connected.",
|
||||
"riskTier": "S3",
|
||||
@@ -58,7 +62,8 @@
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"placeholder": "Paste your Cloudflare API token",
|
||||
"secret": true
|
||||
"secret": true,
|
||||
"helperMd": "Cloudflare API actions selected at provider consent or on the API token. Identity scopes alone do not grant account writes. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions."
|
||||
}
|
||||
],
|
||||
"keyPlacement": {
|
||||
|
||||
@@ -26,7 +26,16 @@
|
||||
],
|
||||
"whenToUse": "Use browser sign-in for the provider-hosted MCP server.",
|
||||
"defaults": {
|
||||
"serverUrl": "https://asset-management.mcp.cloudinary.com/mcp"
|
||||
"serverUrl": "https://asset-management.mcp.cloudinary.com/mcp",
|
||||
"scopesHint": [
|
||||
"openid",
|
||||
"profile",
|
||||
"email",
|
||||
"offline_access",
|
||||
"asset_management",
|
||||
"upload",
|
||||
"media_generation"
|
||||
]
|
||||
},
|
||||
"guidanceMd": "Connect Cloudinary in the browser. A Cloudinary account; authorization is limited by the signed-in user's roles.",
|
||||
"riskTier": "S3",
|
||||
|
||||
@@ -25,7 +25,10 @@
|
||||
],
|
||||
"whenToUse": "Use browser sign-in for the provider-hosted MCP server.",
|
||||
"defaults": {
|
||||
"serverUrl": "https://coda.io/apis/mcp"
|
||||
"serverUrl": "https://coda.io/apis/mcp",
|
||||
"scopesHint": [
|
||||
"mcp:all"
|
||||
]
|
||||
},
|
||||
"guidanceMd": "Connect Coda in the browser. A Coda account; the hosted MCP service is currently beta.",
|
||||
"riskTier": "S3",
|
||||
@@ -59,7 +62,8 @@
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"placeholder": "Paste your Coda API token",
|
||||
"secret": true
|
||||
"secret": true,
|
||||
"helperMd": "Modify documents/tables permitted by the account. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions."
|
||||
}
|
||||
],
|
||||
"keyPlacement": {
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
"required": true,
|
||||
"placeholder": "Paste your Cognee API key",
|
||||
"secret": true,
|
||||
"helperMd": "Create a key in Cognee → API Keys. The key is shown once."
|
||||
"helperMd": "Add/process knowledge and manage authorized datasets. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions."
|
||||
}
|
||||
],
|
||||
"keyPlacement": {
|
||||
|
||||
@@ -25,7 +25,13 @@
|
||||
],
|
||||
"whenToUse": "Use browser sign-in for the provider-hosted MCP server.",
|
||||
"defaults": {
|
||||
"serverUrl": "https://tellme.embat.io/mcp"
|
||||
"serverUrl": "https://tellme.embat.io/mcp",
|
||||
"scopesHint": [
|
||||
"email",
|
||||
"offline_access",
|
||||
"openid",
|
||||
"profile"
|
||||
]
|
||||
},
|
||||
"guidanceMd": "Connect Embat in the browser. An Embat account; pilot the connection because provider setup documentation is sparse.",
|
||||
"riskTier": "S4",
|
||||
|
||||
@@ -62,7 +62,8 @@
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"placeholder": "Paste your Fireflies API key",
|
||||
"secret": true
|
||||
"secret": true,
|
||||
"helperMd": "Use an API key for an account with write access to the meetings your agents need to share, rename, move or turn into soundbites. Paperclip cannot increase the key’s permissions."
|
||||
}
|
||||
],
|
||||
"keyPlacement": {
|
||||
|
||||
@@ -67,7 +67,8 @@
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"placeholder": "github_pat_...",
|
||||
"secret": true
|
||||
"secret": true,
|
||||
"helperMd": "Repository contents, issues, pull requests and other granted toolsets; selected repos and installation/PAT permissions apply. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions."
|
||||
}
|
||||
],
|
||||
"keyPlacement": {
|
||||
|
||||
@@ -36,7 +36,8 @@
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"placeholder": "Paste your Honcho API key",
|
||||
"secret": true
|
||||
"secret": true,
|
||||
"helperMd": "Create peers/sessions and save memory under the API key project. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions."
|
||||
}
|
||||
],
|
||||
"keyPlacement": {
|
||||
|
||||
@@ -27,7 +27,10 @@
|
||||
"defaults": {
|
||||
"serverUrl": "https://huggingface.co/mcp?login&gradio=none",
|
||||
"scopesHint": [
|
||||
"read-mcp"
|
||||
"read-mcp",
|
||||
"read-repos",
|
||||
"contribute-repos",
|
||||
"jobs"
|
||||
]
|
||||
},
|
||||
"guidanceMd": "Connect Hugging Face in the browser. A Hugging Face account.",
|
||||
|
||||
@@ -25,7 +25,10 @@
|
||||
],
|
||||
"whenToUse": "Use browser sign-in for the provider-hosted MCP server.",
|
||||
"defaults": {
|
||||
"serverUrl": "https://mcp.onkernel.com/mcp"
|
||||
"serverUrl": "https://mcp.onkernel.com/mcp",
|
||||
"scopesHint": [
|
||||
"openid"
|
||||
]
|
||||
},
|
||||
"guidanceMd": "Connect Kernel in the browser. A Kernel account with either browser authorization or an API key.",
|
||||
"riskTier": "S3",
|
||||
@@ -58,7 +61,8 @@
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"placeholder": "Paste your Kernel API key",
|
||||
"secret": true
|
||||
"secret": true,
|
||||
"helperMd": "Launch/manage browsers, profiles, apps and browser automation. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions."
|
||||
}
|
||||
],
|
||||
"keyPlacement": {
|
||||
|
||||
@@ -25,7 +25,11 @@
|
||||
],
|
||||
"whenToUse": "Use browser sign-in for the provider-hosted MCP server.",
|
||||
"defaults": {
|
||||
"serverUrl": "https://mcp.localfalcon.com"
|
||||
"serverUrl": "https://mcp.localfalcon.com",
|
||||
"scopesHint": [
|
||||
"api",
|
||||
"offline_access"
|
||||
]
|
||||
},
|
||||
"guidanceMd": "Connect Local Falcon in the browser. A Local Falcon account with MCP access.",
|
||||
"riskTier": "S2",
|
||||
|
||||
@@ -26,7 +26,13 @@
|
||||
],
|
||||
"whenToUse": "Use browser sign-in for the provider-hosted MCP server.",
|
||||
"defaults": {
|
||||
"serverUrl": "https://mcp.manufact.com/mcp"
|
||||
"serverUrl": "https://mcp.manufact.com/mcp",
|
||||
"scopesHint": [
|
||||
"openid",
|
||||
"profile",
|
||||
"email",
|
||||
"offline_access"
|
||||
]
|
||||
},
|
||||
"guidanceMd": "Connect Manufact in the browser. A Manufact account with MCP access.",
|
||||
"riskTier": "S3",
|
||||
|
||||
@@ -36,7 +36,8 @@
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"placeholder": "Paste your Mem0 API key",
|
||||
"secret": true
|
||||
"secret": true,
|
||||
"helperMd": "Add/update/delete memories under the API key account. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions."
|
||||
}
|
||||
],
|
||||
"keyPlacement": {
|
||||
|
||||
@@ -25,7 +25,13 @@
|
||||
],
|
||||
"whenToUse": "Use browser sign-in for the provider-hosted MCP server.",
|
||||
"defaults": {
|
||||
"serverUrl": "https://mcp.miro.com/"
|
||||
"serverUrl": "https://mcp.miro.com/",
|
||||
"scopesHint": [
|
||||
"boards:read",
|
||||
"boards:write",
|
||||
"openid",
|
||||
"email"
|
||||
]
|
||||
},
|
||||
"guidanceMd": "Connect Miro in the browser. A Miro account; enterprise administrators may restrict third-party MCP clients.",
|
||||
"riskTier": "S3",
|
||||
|
||||
@@ -26,7 +26,33 @@
|
||||
],
|
||||
"whenToUse": "Use browser sign-in for the provider-hosted MCP server.",
|
||||
"defaults": {
|
||||
"serverUrl": "https://mcp.mixpanel.com/mcp"
|
||||
"serverUrl": "https://mcp.mixpanel.com/mcp",
|
||||
"scopesHint": [
|
||||
"projects",
|
||||
"analysis",
|
||||
"events",
|
||||
"insights",
|
||||
"segmentation",
|
||||
"retention",
|
||||
"data:read",
|
||||
"funnels",
|
||||
"flows",
|
||||
"data_definitions",
|
||||
"alerts",
|
||||
"annotations",
|
||||
"bookmarks",
|
||||
"business_context",
|
||||
"cohorts",
|
||||
"context",
|
||||
"custom_alerts",
|
||||
"custom_events",
|
||||
"custom_properties",
|
||||
"dashboard_reports",
|
||||
"experiments",
|
||||
"feature_flags",
|
||||
"metrics",
|
||||
"user_details"
|
||||
]
|
||||
},
|
||||
"guidanceMd": "Connect Mixpanel in the browser. A Mixpanel account; the hosted MCP server is currently beta.",
|
||||
"riskTier": "S3",
|
||||
|
||||
@@ -26,7 +26,12 @@
|
||||
],
|
||||
"whenToUse": "Use browser sign-in for the provider-hosted MCP server.",
|
||||
"defaults": {
|
||||
"serverUrl": "https://netlify-mcp.netlify.app/mcp"
|
||||
"serverUrl": "https://netlify-mcp.netlify.app/mcp",
|
||||
"scopesHint": [
|
||||
"offline_access",
|
||||
"read",
|
||||
"write"
|
||||
]
|
||||
},
|
||||
"guidanceMd": "Connect Netlify in the browser. A Netlify account with access to the relevant team and sites.",
|
||||
"riskTier": "S3",
|
||||
|
||||
@@ -24,7 +24,10 @@
|
||||
],
|
||||
"whenToUse": "Use the provider-hosted connection for the quickest setup.",
|
||||
"defaults": {
|
||||
"serverUrl": "https://mcp.notion.com/mcp"
|
||||
"serverUrl": "https://mcp.notion.com/mcp",
|
||||
"scopesHint": [
|
||||
"default"
|
||||
]
|
||||
},
|
||||
"guidanceMd": "Connect Notion for workspace content. Share only the pages and databases agents should use.",
|
||||
"riskTier": "S3",
|
||||
|
||||
@@ -25,7 +25,11 @@
|
||||
],
|
||||
"whenToUse": "Use browser sign-in for the provider-hosted MCP server.",
|
||||
"defaults": {
|
||||
"serverUrl": "https://api.oreilly.com/api/content-discovery/v1/mcp/"
|
||||
"serverUrl": "https://api.oreilly.com/api/content-discovery/v1/mcp/",
|
||||
"scopesHint": [
|
||||
"openid",
|
||||
"offline_access"
|
||||
]
|
||||
},
|
||||
"guidanceMd": "Connect O'Reilly in the browser. An O'Reilly Learning subscription with MCP or API access.",
|
||||
"riskTier": "S2",
|
||||
@@ -58,7 +62,8 @@
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"placeholder": "Paste your O'Reilly API token",
|
||||
"secret": true
|
||||
"secret": true,
|
||||
"helperMd": "None: content discovery and retrieval. A read-only key is sufficient."
|
||||
}
|
||||
],
|
||||
"keyPlacement": {
|
||||
|
||||
@@ -36,7 +36,8 @@
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"placeholder": "Paste your PagerDuty user API token",
|
||||
"secret": true
|
||||
"secret": true,
|
||||
"helperMd": "Incident and on-call changes require a full-access API key and role access. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions."
|
||||
}
|
||||
],
|
||||
"keyPlacement": {
|
||||
@@ -73,7 +74,8 @@
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"placeholder": "Paste your PagerDuty user API token",
|
||||
"secret": true
|
||||
"secret": true,
|
||||
"helperMd": "Incident and on-call changes require a full-access API key and role access. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions."
|
||||
}
|
||||
],
|
||||
"keyPlacement": {
|
||||
|
||||
@@ -59,7 +59,12 @@
|
||||
"organization",
|
||||
"database",
|
||||
"branch"
|
||||
]
|
||||
],
|
||||
"capabilityProfile": {
|
||||
"key": "write",
|
||||
"label": "Read and write",
|
||||
"description": "Query and change the databases you authorize in PlanetScale."
|
||||
}
|
||||
},
|
||||
{
|
||||
"key": "mcp-insights-only",
|
||||
@@ -85,7 +90,12 @@
|
||||
"organization",
|
||||
"database",
|
||||
"branch"
|
||||
]
|
||||
],
|
||||
"capabilityProfile": {
|
||||
"key": "read",
|
||||
"label": "Read only",
|
||||
"description": "Inspect database performance with the insights-only server."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -241,7 +241,8 @@
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"placeholder": "phx_...",
|
||||
"secret": true
|
||||
"secret": true,
|
||||
"helperMd": "Analytics objects, feature flags, experiments, error triage and other enabled product actions. readonly/feature/tool filters remain enforced. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions."
|
||||
}
|
||||
],
|
||||
"keyPlacement": {
|
||||
|
||||
@@ -115,7 +115,8 @@
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"placeholder": "PMAK-...",
|
||||
"secret": true
|
||||
"secret": true,
|
||||
"helperMd": "Collection/workspace/API actions supported by the selected minimal/code/full endpoint and account role. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions."
|
||||
}
|
||||
],
|
||||
"keyPlacement": {
|
||||
@@ -157,7 +158,8 @@
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"placeholder": "PMAK-...",
|
||||
"secret": true
|
||||
"secret": true,
|
||||
"helperMd": "Collection/workspace/API actions supported by the selected minimal/code/full endpoint and account role. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions."
|
||||
}
|
||||
],
|
||||
"keyPlacement": {
|
||||
@@ -199,7 +201,8 @@
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"placeholder": "PMAK-...",
|
||||
"secret": true
|
||||
"secret": true,
|
||||
"helperMd": "Collection/workspace/API actions supported by the selected minimal/code/full endpoint and account role. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions."
|
||||
}
|
||||
],
|
||||
"keyPlacement": {
|
||||
|
||||
@@ -60,7 +60,8 @@
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"placeholder": "Paste the base64-encoded key ID and secret",
|
||||
"secret": true
|
||||
"secret": true,
|
||||
"helperMd": "Provider-authorized payment/account tools; financial policy gates remain. Live verification outstanding. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions."
|
||||
}
|
||||
],
|
||||
"keyPlacement": {
|
||||
|
||||
@@ -26,7 +26,10 @@
|
||||
],
|
||||
"whenToUse": "Use browser sign-in for the provider-hosted MCP server.",
|
||||
"defaults": {
|
||||
"serverUrl": "https://mcp.resend.com/mcp"
|
||||
"serverUrl": "https://mcp.resend.com/mcp",
|
||||
"scopesHint": [
|
||||
"full_access"
|
||||
]
|
||||
},
|
||||
"guidanceMd": "Connect Resend in the browser. A Resend account with access to the relevant domains.",
|
||||
"riskTier": "S3",
|
||||
|
||||
@@ -26,7 +26,10 @@
|
||||
],
|
||||
"whenToUse": "Use browser sign-in for the provider-hosted MCP server.",
|
||||
"defaults": {
|
||||
"serverUrl": "https://mcp.sanity.io"
|
||||
"serverUrl": "https://mcp.sanity.io",
|
||||
"scopesHint": [
|
||||
"global"
|
||||
]
|
||||
},
|
||||
"guidanceMd": "Connect Sanity in the browser. A Sanity account with access to the relevant projects and datasets.",
|
||||
"riskTier": "S3",
|
||||
@@ -59,7 +62,8 @@
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"placeholder": "sk...",
|
||||
"secret": true
|
||||
"secret": true,
|
||||
"helperMd": "Edit content permitted by project role; global is MCP authorization, not a bypass of project ACLs. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions."
|
||||
}
|
||||
],
|
||||
"keyPlacement": {
|
||||
|
||||
@@ -26,7 +26,14 @@
|
||||
"whenToUse": "Use the provider-hosted connection for the quickest setup.",
|
||||
"defaults": {
|
||||
"serverUrl": "https://mcp.sentry.dev/mcp",
|
||||
"discoveryUrl": "https://sentry.io/.well-known/oauth-authorization-server"
|
||||
"discoveryUrl": "https://sentry.io/.well-known/oauth-authorization-server",
|
||||
"scopesHint": [
|
||||
"org:read",
|
||||
"project:write",
|
||||
"team:write",
|
||||
"event:write",
|
||||
"alerts:write"
|
||||
]
|
||||
},
|
||||
"guidanceMd": "Connect the Sentry organization and projects agents need for incident work.",
|
||||
"riskTier": "S2",
|
||||
|
||||
@@ -37,7 +37,8 @@
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"placeholder": "Paste your Similarweb API key",
|
||||
"secret": true
|
||||
"secret": true,
|
||||
"helperMd": "None: analytics retrieval; subscription entitlements apply. A read-only key is sufficient."
|
||||
}
|
||||
],
|
||||
"keyPlacement": {
|
||||
|
||||
@@ -28,9 +28,36 @@
|
||||
"authorizationEndpoint": "https://slack.com/oauth/v2/authorize",
|
||||
"tokenEndpoint": "https://slack.com/api/oauth.v2.access",
|
||||
"scopesHint": [
|
||||
"canvases:read",
|
||||
"canvases:write",
|
||||
"channels:history",
|
||||
"channels:read",
|
||||
"channels:write",
|
||||
"chat:write",
|
||||
"search:read"
|
||||
"emoji:read",
|
||||
"files:read",
|
||||
"files:write",
|
||||
"groups:history",
|
||||
"groups:read",
|
||||
"groups:write",
|
||||
"im:history",
|
||||
"im:read",
|
||||
"im:write",
|
||||
"lists:read",
|
||||
"lists:write",
|
||||
"mpim:history",
|
||||
"mpim:read",
|
||||
"mpim:write",
|
||||
"reactions:read",
|
||||
"reactions:write",
|
||||
"search:read.files",
|
||||
"search:read.im",
|
||||
"search:read.mpim",
|
||||
"search:read.private",
|
||||
"search:read.public",
|
||||
"search:read.users",
|
||||
"users:read",
|
||||
"users:read.email"
|
||||
]
|
||||
},
|
||||
"guidanceMd": "Connect a Slack workspace and limit access to the channels agents need.",
|
||||
|
||||
@@ -25,7 +25,10 @@
|
||||
],
|
||||
"whenToUse": "Use browser sign-in for the provider-hosted MCP server.",
|
||||
"defaults": {
|
||||
"serverUrl": "https://mcp.stripe.com"
|
||||
"serverUrl": "https://mcp.stripe.com",
|
||||
"scopesHint": [
|
||||
"mcp"
|
||||
]
|
||||
},
|
||||
"guidanceMd": "Connect Stripe in the browser. A Stripe account; the server is public preview and payment actions require explicit approval.",
|
||||
"riskTier": "S4",
|
||||
@@ -59,7 +62,8 @@
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"placeholder": "sk_...",
|
||||
"secret": true
|
||||
"secret": true,
|
||||
"helperMd": "Account- and sandbox-specific writes selected during Stripe consent; financial approvals and restricted-key permissions remain. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions."
|
||||
}
|
||||
],
|
||||
"keyPlacement": {
|
||||
|
||||
@@ -25,7 +25,22 @@
|
||||
],
|
||||
"whenToUse": "Use browser sign-in for the provider-hosted MCP server.",
|
||||
"defaults": {
|
||||
"serverUrl": "https://mcp.supabase.com/mcp"
|
||||
"serverUrl": "https://mcp.supabase.com/mcp",
|
||||
"scopesHint": [
|
||||
"organizations:read",
|
||||
"projects:read",
|
||||
"projects:write",
|
||||
"database:write",
|
||||
"database:read",
|
||||
"analytics:read",
|
||||
"secrets:read",
|
||||
"edge_functions:read",
|
||||
"edge_functions:write",
|
||||
"environment:read",
|
||||
"environment:write",
|
||||
"storage:read",
|
||||
"storage:write"
|
||||
]
|
||||
},
|
||||
"guidanceMd": "Connect Supabase in the browser and scope the connection to one development project. Write tools start enabled and remain governed by Paperclip's action policies.",
|
||||
"riskTier": "S4",
|
||||
@@ -60,7 +75,8 @@
|
||||
"location": "query",
|
||||
"name": "read_only",
|
||||
"format": "boolean"
|
||||
}
|
||||
},
|
||||
"advanced": true
|
||||
},
|
||||
{
|
||||
"key": "features",
|
||||
@@ -101,7 +117,8 @@
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"placeholder": "sbp_...",
|
||||
"secret": true
|
||||
"secret": true,
|
||||
"helperMd": "Project/database/environment/storage changes and Edge Function deployment. Project selection and optional read-only configuration remain authoritative. Create a key with read and write permissions for these actions; Paperclip cannot increase an existing key’s permissions."
|
||||
}
|
||||
],
|
||||
"keyPlacement": {
|
||||
@@ -140,7 +157,8 @@
|
||||
"location": "query",
|
||||
"name": "read_only",
|
||||
"format": "boolean"
|
||||
}
|
||||
},
|
||||
"advanced": true
|
||||
},
|
||||
{
|
||||
"key": "features",
|
||||
|
||||
@@ -25,7 +25,11 @@
|
||||
],
|
||||
"whenToUse": "Use browser sign-in for the provider-hosted MCP server.",
|
||||
"defaults": {
|
||||
"serverUrl": "https://mcp.ticktick.com"
|
||||
"serverUrl": "https://mcp.ticktick.com",
|
||||
"scopesHint": [
|
||||
"tasks:write",
|
||||
"tasks:read"
|
||||
]
|
||||
},
|
||||
"guidanceMd": "Connect TickTick in the browser. A TickTick account with MCP access.",
|
||||
"riskTier": "S3",
|
||||
|
||||
@@ -25,7 +25,10 @@
|
||||
],
|
||||
"whenToUse": "Use browser sign-in for the provider-hosted MCP server.",
|
||||
"defaults": {
|
||||
"serverUrl": "https://ai.todoist.net/mcp"
|
||||
"serverUrl": "https://ai.todoist.net/mcp",
|
||||
"scopesHint": [
|
||||
"data:read_write"
|
||||
]
|
||||
},
|
||||
"guidanceMd": "Connect Todoist in the browser. A Todoist account.",
|
||||
"riskTier": "S3",
|
||||
|
||||
@@ -25,7 +25,10 @@
|
||||
],
|
||||
"whenToUse": "Use the provider-hosted connection for the quickest setup.",
|
||||
"defaults": {
|
||||
"serverUrl": "https://mcp.vercel.com/mcp"
|
||||
"serverUrl": "https://mcp.vercel.com/mcp",
|
||||
"scopesHint": [
|
||||
"openid"
|
||||
]
|
||||
},
|
||||
"guidanceMd": "Connect the Vercel team and projects agents should operate.",
|
||||
"riskTier": "S3",
|
||||
|
||||
@@ -26,7 +26,10 @@
|
||||
],
|
||||
"whenToUse": "Use browser sign-in for the provider-hosted MCP server.",
|
||||
"defaults": {
|
||||
"serverUrl": "https://mcp.wix.com/mcp"
|
||||
"serverUrl": "https://mcp.wix.com/mcp",
|
||||
"scopesHint": [
|
||||
"offline_access"
|
||||
]
|
||||
},
|
||||
"guidanceMd": "Connect Wix in the browser. A Wix account with access to the relevant sites.",
|
||||
"riskTier": "S3",
|
||||
|
||||
@@ -26,7 +26,11 @@
|
||||
],
|
||||
"whenToUse": "Use browser sign-in for the provider-hosted MCP server.",
|
||||
"defaults": {
|
||||
"serverUrl": "https://api.you.com/mcp"
|
||||
"serverUrl": "https://api.you.com/mcp",
|
||||
"scopesHint": [
|
||||
"Tools",
|
||||
"offline_access"
|
||||
]
|
||||
},
|
||||
"guidanceMd": "Connect You.com in the browser. A You.com account for browser sign-in, or a You.com API key from you.com/platform for higher rate limits; a keyless free profile is also available.",
|
||||
"riskTier": "S2",
|
||||
@@ -59,7 +63,8 @@
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"placeholder": "Paste your You.com API key",
|
||||
"secret": true
|
||||
"secret": true,
|
||||
"helperMd": "None: search, contents and research tools (usage may be billed). A read-only key is sufficient."
|
||||
}
|
||||
],
|
||||
"keyPlacement": {
|
||||
|
||||
@@ -321,6 +321,7 @@ export {
|
||||
connectionMethodSupportsCatalogSetup,
|
||||
connectionMethodSupportsAutomaticOAuth,
|
||||
credentialConfigPath,
|
||||
connectionCredentialConfigPath,
|
||||
getAppDefinitionForUrl,
|
||||
getAppStoreDefinition,
|
||||
getAvailableConnectionMethod,
|
||||
|
||||
@@ -1627,6 +1627,39 @@ for (const [slug, name, subscription, envKey] of [["anthropic", "Claude", true,
|
||||
// AI account flow; saved REST connections remain removable through Connections.
|
||||
app.methods = [...methods, ...app.methods.filter(method => method.transport !== "rest_api")];
|
||||
}
|
||||
// Every tool method has a checked-in permission review. Discovery metadata is
|
||||
// evidence for reviewers, never a runtime instruction to request more scopes.
|
||||
const permissionReviews = JSON.parse(fs.readFileSync(
|
||||
path.join(root, "doc/connections/tool-method-permission-reviews.json"), "utf8",
|
||||
)).methods;
|
||||
for (const app of apps) {
|
||||
for (const connectionMethod of app.methods) {
|
||||
if (["channel", "ai"].includes(connectionMethod.purpose)) continue;
|
||||
const review = permissionReviews.find((entry) => entry.app === app.slug && entry.method === connectionMethod.key);
|
||||
if (!review) throw new Error(`${app.slug}/${connectionMethod.key}: permission review required`);
|
||||
if (connectionMethod.auth === "oauth") {
|
||||
if (review.policy === "explicit") {
|
||||
connectionMethod.defaults = { ...connectionMethod.defaults, scopesHint: review.requestedScopes };
|
||||
} else if (review.policy !== "provider-default" || !review.providerDefaultReason) {
|
||||
throw new Error(`${app.slug}/${connectionMethod.key}: reviewed scopes or documented provider default required`);
|
||||
}
|
||||
}
|
||||
for (const configField of connectionMethod.tenantFields ?? []) {
|
||||
if (configField.key === "readOnly") configField.advanced = true;
|
||||
}
|
||||
if (review.keyPermissions) {
|
||||
for (const credential of connectionMethod.credentialFields ?? []) {
|
||||
if (credential.secret !== false) credential.helperMd = review.keyPermissions;
|
||||
}
|
||||
}
|
||||
if (app.slug === "planetscale") {
|
||||
connectionMethod.capabilityProfile = connectionMethod.key === "mcp-insights-only"
|
||||
? { key: "read", label: "Read only", description: "Inspect database performance with the insights-only server." }
|
||||
: { key: "write", label: "Read and write", description: "Query and change the databases you authorize in PlanetScale." };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const validateApp = (app) => {
|
||||
if (
|
||||
app.schemaVersion !== 1 ||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { companies, companyMemberships, companySecrets, connectionGrants, connectionGrantMembers, toolApplications, toolConnections, createDb, projects as projectTable } from "@paperclipai/db";
|
||||
import { companies, companyMemberships, companySecrets, connectionGrants, connectionGrantMembers, toolApplications, toolConnections, createDb, projects as projectTable, userSecretDefinitions } from "@paperclipai/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { toolAccessService } from "../services/tool-access.js";
|
||||
import { projectService } from "../services/projects.js";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
|
||||
const personalTokens = vi.hoisted(() => new Map<string, string>());
|
||||
vi.mock("../services/secrets.js", () => ({ secretService: () => ({
|
||||
resolveSecretValue: async (_companyId: string, secretId: string) => secretId,
|
||||
resolveUserSecretValue: async (_companyId: string, input: { definitionId: string }) => ({ value: personalTokens.get(input.definitionId) }),
|
||||
}) }));
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
@@ -21,7 +23,7 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
[companyId] = (await db.insert(companies).values({ name: "Repositories", issuePrefix: "REPO" }).returning()).map((company) => company.id);
|
||||
}, 20_000);
|
||||
afterAll(async () => { await temp?.cleanup(); });
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
afterEach(() => { vi.unstubAllGlobals(); personalTokens.clear(); });
|
||||
|
||||
it("creates multiple source repos atomically and persists them across reads", async () => {
|
||||
const svc = projectService(db);
|
||||
@@ -69,9 +71,15 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "alice", membershipRole: "admin" });
|
||||
const [otherCompany] = await db.insert(companies).values({ name: "Other", issuePrefix: "OTHER" }).returning();
|
||||
const tokenRepos = new Map<string, Array<{ id: number; full_name: string }>>();
|
||||
async function connection(name: string, kind: "user" | "organization", owner: string | null, audience: string[] = [], targetCompanyId = companyId) {
|
||||
async function connection(name: string, kind: "user" | "organization", owner: string | null, audience: string[] = [], targetCompanyId = companyId, legacyCompanyCredential = false) {
|
||||
const [app] = await db.insert(toolApplications).values({ companyId: targetCompanyId, name, type: "mcp_http" }).returning();
|
||||
const [secret] = await db.insert(companySecrets).values({ companyId: targetCompanyId, name, key: name }).returning();
|
||||
const definition = kind === "user" && !legacyCompanyCredential
|
||||
? (await db.insert(userSecretDefinitions).values({ companyId: targetCompanyId, name, key: name }).returning())[0]
|
||||
: undefined;
|
||||
const [secret] = await db.insert(companySecrets).values({ companyId: targetCompanyId, name, key: name,
|
||||
scope: definition ? "user" : "company", ownerUserId: definition ? owner : null,
|
||||
userSecretDefinitionId: definition?.id }).returning();
|
||||
if (definition) personalTokens.set(definition.id, secret.id);
|
||||
const [conn] = await db.insert(toolConnections).values({ companyId: targetCompanyId, applicationId: app.id, name, uid: name, status: "active", enabled: true,
|
||||
transport: "mcp_remote", authKind: "api_key", credentialPolicy: kind === "user" ? "per_user" : "shared", config: { sourceTemplateKey: "github" } }).returning();
|
||||
const [grant] = await db.insert(connectionGrants).values({ companyId: targetCompanyId, connectionId: conn.id, kind, subjectUserId: owner,
|
||||
@@ -85,18 +93,19 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
const restricted = await connection("restricted", "organization", null, ["bob"]);
|
||||
const crossCompany = await connection("cross-company", "organization", null, [], otherCompany.id);
|
||||
const broken = await connection("broken", "organization", null);
|
||||
const legacyPersonal = await connection("legacy-personal", "user", "alice", [], companyId, true);
|
||||
tokenRepos.set(personal, [{ id: 10, full_name: "org/common" }, { id: 11, full_name: "alice/private" }]);
|
||||
tokenRepos.set(shared, [{ id: 10, full_name: "org/common" }, { id: 12, full_name: "org/shared" }]);
|
||||
const request = vi.fn<typeof fetch>(async (_url, init) => {
|
||||
const token = new Headers(init?.headers).get("authorization")?.replace("Bearer ", "") ?? "";
|
||||
if (token === broken) return new Response("secret-provider-error", { status: 503 });
|
||||
expect([otherPerson, restricted, crossCompany]).not.toContain(token);
|
||||
expect([otherPerson, restricted, crossCompany, legacyPersonal]).not.toContain(token);
|
||||
return Response.json(tokenRepos.get(token) ?? []);
|
||||
});
|
||||
vi.stubGlobal("fetch", request);
|
||||
const result = await toolAccessService(db).listProjectRepositories(companyId, "alice");
|
||||
expect(result.connectionCount).toBe(3);
|
||||
expect(result.failedConnectionCount).toBe(1);
|
||||
expect(result.connectionCount).toBe(4);
|
||||
expect(result.failedConnectionCount).toBe(2);
|
||||
expect(result.repositories.map((repo) => repo.id).sort()).toEqual(["10", "11", "12"]);
|
||||
expect(result.repositories.find((repo) => repo.id === "10")?.connections.sort()).toEqual(["personal", "shared"]);
|
||||
expect(JSON.stringify(result)).not.toContain("secret-provider-error");
|
||||
@@ -107,7 +116,7 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
expect(await toolAccessService(db).githubReadConnectionIds(otherCompany.id, "alice")).toEqual([]);
|
||||
const ownConnection = savedConnections.find(connection => connection.name === "personal")!;
|
||||
expect(await toolAccessService(db).githubReadHeaders(companyId, ownConnection.id, "alice")).toEqual({ Authorization: `Bearer ${personal}` });
|
||||
for (const name of ["other-person", "restricted"]) {
|
||||
for (const name of ["other-person", "restricted", "legacy-personal"]) {
|
||||
await expect(toolAccessService(db).githubReadHeaders(companyId, savedConnections.find(connection => connection.name === name)!.id, "alice")).rejects.toThrow(/authorization/);
|
||||
}
|
||||
await expect(toolAccessService(db).githubReadHeaders(otherCompany.id, ownConnection.id, "alice", true)).rejects.toThrow(/unavailable/);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as fs from "node:fs/promises";
|
||||
import { createHash, generateKeyPairSync, randomUUID } from "node:crypto";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
@@ -2440,6 +2441,34 @@ describeEmbeddedPostgres("tool access service", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["airtable", "beehiiv", "miro", "netlify", "sentry", "supabase", "todoist", "ticktick", "hugging-face"])(
|
||||
"requests the reviewed read/write scopes for %s without adopting advertised admin scopes",
|
||||
async (slug) => {
|
||||
const company = await createCompany(db);
|
||||
const actor = { actorType: "user" as const, actorId: "alice" };
|
||||
const service = createTestToolAccessService(db, {
|
||||
remoteHttpRequest: async (url, init) => {
|
||||
const origin = new URL(url).origin;
|
||||
if (init.method === "POST") return Response.json({ client_id: "fixture-client", ...JSON.parse(String(init.body)) });
|
||||
return Response.json({ issuer: origin, authorization_endpoint: `${origin}/authorize`,
|
||||
token_endpoint: `${origin}/token`, registration_endpoint: `${origin}/register`,
|
||||
scopes_supported: ["unrelated:admin"], response_types_supported: ["code"],
|
||||
code_challenge_methods_supported: ["S256"], token_endpoint_auth_methods_supported: ["none"],
|
||||
});
|
||||
},
|
||||
});
|
||||
const connected = await service.connectGalleryApp(company.id, { galleryKey: slug, connectionMethodKey: "mcp-oauth", grantKind: "user", ...(slug === "supabase" ? { configValues: { projectRef: "abcdefghijklmnopqrst" } } : {}) }, actor);
|
||||
const started = await service.startOAuth(company.id, connected.connectionId, { redirectUri: "https://paperclip.example.test/api/tools/oauth/callback", actor });
|
||||
const review = JSON.parse(await fs.readFile(new URL("../../../doc/connections/tool-method-permission-reviews.json", import.meta.url), "utf8"))
|
||||
.methods.find((entry: { app: string; method: string }) => entry.app === slug && entry.method === "mcp-oauth");
|
||||
expect(new URL(started.authorizationUrl).searchParams.get("scope")?.split(" ")).toEqual(review.requestedScopes);
|
||||
expect(new URL(started.authorizationUrl).searchParams.get("scope")).not.toContain("unrelated:admin");
|
||||
await expect(service.startOAuth(company.id, connected.connectionId, {
|
||||
redirectUri: "https://paperclip.example.test/api/tools/oauth/callback", actor, scopes: ["unrelated:admin"],
|
||||
})).rejects.toMatchObject({ details: { code: "oauth_scope_widening_rejected" } });
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps tools outside a Google Workspace capability profile disabled", async () => {
|
||||
const company = await createCompany(db);
|
||||
const service = createTestToolAccessService(db);
|
||||
@@ -8011,7 +8040,6 @@ describeEmbeddedPostgres("tool access service", () => {
|
||||
),
|
||||
);
|
||||
expect(bindings.map((binding) => binding.configPath).sort()).toEqual([
|
||||
"credentials.oauth.access_token",
|
||||
"oauth.access_token",
|
||||
"oauth.refresh_token",
|
||||
]);
|
||||
@@ -10365,7 +10393,7 @@ describeEmbeddedPostgres("tool access service", () => {
|
||||
await expect(db.select().from(toolOauthStates)).resolves.toHaveLength(0);
|
||||
await expect(
|
||||
db.select().from(companySecretBindings),
|
||||
).resolves.toHaveLength(6);
|
||||
).resolves.toHaveLength(4);
|
||||
const [connection] = await db
|
||||
.select()
|
||||
.from(toolConnections)
|
||||
@@ -10879,7 +10907,7 @@ describeEmbeddedPostgres("tool access service", () => {
|
||||
);
|
||||
expect(
|
||||
new URL(first.authorizationUrl).searchParams.get("scope"),
|
||||
).toBeNull();
|
||||
).toBe("default");
|
||||
expect(
|
||||
new URL(concurrent.authorizationUrl).searchParams.get("client_id"),
|
||||
).toBe("notion-dcr-client");
|
||||
@@ -10972,7 +11000,7 @@ describeEmbeddedPostgres("tool access service", () => {
|
||||
clientTokenEndpointAuthMethod: "none",
|
||||
clientRedirectUri: redirectUri,
|
||||
registrationUrl: "https://mcp.notion.com/register",
|
||||
scopes: [],
|
||||
scopes: ["default"],
|
||||
},
|
||||
});
|
||||
expect(connection.credentialSecretRefs).toEqual([
|
||||
@@ -12042,7 +12070,7 @@ describeEmbeddedPostgres("tool access service", () => {
|
||||
outcome: "success",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
configPath: "credentials.oauth.access_token",
|
||||
configPath: "oauth.access_token",
|
||||
outcome: "success",
|
||||
}),
|
||||
]),
|
||||
@@ -15545,7 +15573,7 @@ describeEmbeddedPostgres("tool access service", () => {
|
||||
{ actorType: "user", actorId: "board" },
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
message: expect.stringContaining("Paste a new key"),
|
||||
message: expect.stringContaining("Enter a replacement credential"),
|
||||
});
|
||||
|
||||
const result = await service.reconnectGalleryApp(
|
||||
|
||||
@@ -16,6 +16,9 @@ import {
|
||||
createDb,
|
||||
heartbeatRuns,
|
||||
issues,
|
||||
managedAgentProfiles,
|
||||
routines,
|
||||
routineTriggers,
|
||||
secretAccessEvents,
|
||||
toolAccessAuditEvents,
|
||||
toolApplications,
|
||||
@@ -180,6 +183,8 @@ describeEmbeddedPostgres("tool connection removal", () => {
|
||||
await db.delete(secretAccessEvents);
|
||||
await db.delete(companySecretBindings);
|
||||
await db.delete(companySecretVersions);
|
||||
await db.delete(managedAgentProfiles);
|
||||
await db.delete(routines);
|
||||
await db.delete(companySecrets);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(toolAccessAuditEvents);
|
||||
@@ -245,7 +250,7 @@ describeEmbeddedPostgres("tool connection removal", () => {
|
||||
await expect(secretService(db).resolveSecretValue(company.id, secretIds[0]!, "latest", {
|
||||
consumerType: "tool_connection",
|
||||
consumerId: connectionId,
|
||||
configPath: `credentials.headers.${HEADER.name}`,
|
||||
configPath: `headers.${HEADER.name}`,
|
||||
actorType: "system",
|
||||
})).resolves.toBe(HEADER.value);
|
||||
await expect(policy.decide({
|
||||
@@ -278,7 +283,7 @@ describeEmbeddedPostgres("tool connection removal", () => {
|
||||
await expect(secretService(db).resolveSecretValue(company.id, secretId, "latest", {
|
||||
consumerType: "tool_connection",
|
||||
consumerId: connectionId,
|
||||
configPath: `credentials.headers.${HEADER.name}`,
|
||||
configPath: `headers.${HEADER.name}`,
|
||||
actorType: "system",
|
||||
})).rejects.toMatchObject({ status: 404 });
|
||||
}
|
||||
@@ -399,6 +404,26 @@ describeEmbeddedPostgres("tool connection removal", () => {
|
||||
expect(issuance!.path).toBe("oauth_access");
|
||||
});
|
||||
|
||||
it.each(["managed-profile", "routine-trigger"])("retains a credential referenced directly by a %s", async (consumer) => {
|
||||
installMcpFixture(HEADER);
|
||||
const company = await createCompany(db);
|
||||
const agent = await createAgent(db, company.id);
|
||||
const { service, connectionId } = await connectHeaderApp(company.id, agent.id);
|
||||
const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, connectionId));
|
||||
const secretId = connection!.credentialRefs[0]!.secretId;
|
||||
if (consumer === "managed-profile") {
|
||||
await db.insert(managedAgentProfiles).values({ companyId: company.id, profileKey: "shared", displayName: "Shared",
|
||||
anthropicAgentId: "fixture", agentVersion: "1", environmentId: "fixture", apiKeySecretId: secretId });
|
||||
} else {
|
||||
const [routine] = await db.insert(routines).values({ companyId: company.id, title: "Shared" }).returning();
|
||||
await db.insert(routineTriggers).values({ companyId: company.id, routineId: routine!.id, kind: "webhook", secretId });
|
||||
}
|
||||
const removed = await service.archiveConnection(connectionId, company.id);
|
||||
expect(removed.removal.secretsRetainedShared).toBe(1);
|
||||
expect((await db.select().from(companySecrets).where(eq(companySecrets.id, secretId)))[0]!.status).toBe("active");
|
||||
await expect(secretService(db).resolveSecretValue(company.id, secretId, "latest")).resolves.toBe(HEADER.value);
|
||||
});
|
||||
|
||||
it("leaves another consumer's credential in place", async () => {
|
||||
installMcpFixture(HEADER);
|
||||
const company = await createCompany(db);
|
||||
@@ -513,7 +538,7 @@ describeEmbeddedPostgres("tool connection removal", () => {
|
||||
await expect(secretService(db).resolveSecretValue(company.id, secretIds[0]!, "latest", {
|
||||
consumerType: "tool_connection",
|
||||
consumerId: connectionId,
|
||||
configPath: `credentials.headers.${HEADER.name}`,
|
||||
configPath: `headers.${HEADER.name}`,
|
||||
actorType: "system",
|
||||
})).rejects.toMatchObject({ status: 404 });
|
||||
// The ref survives the failure on purpose: it is the only pointer a retry
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { createServer, type IncomingMessage } from "node:http";
|
||||
import express from "express";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import request from "supertest";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
summarizeToolValue,
|
||||
} from "../services/tool-content-guards.js";
|
||||
import { createToolGatewayService, ToolGatewayHttpError } from "../services/tool-gateway.js";
|
||||
import { resolveConnectionGrantSecret } from "../services/connection-credentials.js";
|
||||
import { secretService } from "../services/secrets.js";
|
||||
import * as cogneeBridge from "../services/cognee-connection.js";
|
||||
import { createKvDemoHttpServer, type KvDemoHttpServer } from "../../../packages/kv-demo-mcp-server/src/http.js";
|
||||
@@ -546,7 +547,7 @@ describeEmbeddedPostgres("tool gateway acceptance", () => {
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-tool-gateway-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 20_000);
|
||||
}, 90_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(activityLog);
|
||||
@@ -2466,6 +2467,177 @@ rl.on("line", (line) => {
|
||||
}
|
||||
});
|
||||
|
||||
it.each(([
|
||||
"zapier", "url", "bearer", "header", "public",
|
||||
] as const).flatMap((kind) => (kind === "public" ? ["setup"] : ["setup", "inline"])
|
||||
.map((reconnectMode) => ({ kind, reconnectMode }))))(
|
||||
"executes $kind setup and $reconnectMode reconnect through a run-scoped gateway with canonical vault declarations",
|
||||
async ({ kind, reconnectMode }) => {
|
||||
for (const grantKind of ["user", "organization"] as const) {
|
||||
const company = await createCompany(db);
|
||||
const agent = await createAgent(db, company.id);
|
||||
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
||||
await createActiveMember(db, company.id, "alice");
|
||||
await db.update(heartbeatRuns).set({ responsibleUserId: "alice" }).where(eq(heartbeatRuns.id, run.id));
|
||||
const secretUrl = kind === "zapier"
|
||||
? "https://mcp.zapier.com/api/v1/connect?token=fixture-canary"
|
||||
: "https://8.8.8.8/mcp?token=fixture-canary";
|
||||
const calls: { url: string; headers: Headers; method: unknown }[] = [];
|
||||
const remoteHttpRequest: NonNullable<ToolGatewayServiceOptions["remoteHttpRequest"]> = async (url, init) => {
|
||||
const body = JSON.parse(String(init.body ?? "{}"));
|
||||
calls.push({ url: String(url), headers: new Headers(init.headers), method: body.method });
|
||||
if (body.method === "notifications/initialized") return new Response(null, { status: 202 });
|
||||
return Response.json({ jsonrpc: "2.0", id: body.id, result:
|
||||
body.method === "initialize" ? { protocolVersion: "2025-06-18", capabilities: { tools: {} }, serverInfo: { name: "fixture", version: "1" } }
|
||||
: body.method === "tools/list" ? { tools: [
|
||||
{ name: "read_fixture", inputSchema: { type: "object" }, annotations: { readOnlyHint: true } },
|
||||
{ name: "write_fixture", inputSchema: { type: "object" }, annotations: { readOnlyHint: false, destructiveHint: false } },
|
||||
] } : { content: [{ type: "text", text: "performed" }] },
|
||||
});
|
||||
};
|
||||
const options = { remoteHttpRequest, remoteHttpEndpointLookup: async () => [{ address: "8.8.8.8", family: 4 }] };
|
||||
const service = toolAccessService(db, options);
|
||||
const setupInput: Parameters<typeof service.connectGalleryApp>[1] = {
|
||||
name: `Fixture ${kind}`, grantKind,
|
||||
...(kind === "zapier" ? { galleryKey: "zapier", connectionMethodKey: "generated-url", link: secretUrl }
|
||||
: kind === "url" ? { link: secretUrl }
|
||||
: kind === "bearer" ? { link: "https://8.8.8.8/mcp", authMode: "bearer" as const, credentialValues: { "credentials.authorization": "fixture-canary" } }
|
||||
: kind === "header" ? { link: "https://8.8.8.8/mcp", authMode: "custom_headers" as const, credentialValues: { "headers.X-Api-Key": "fixture-canary" } }
|
||||
: { link: "https://8.8.8.8/mcp", authMode: "none" as const }),
|
||||
};
|
||||
const connected = await service.connectGalleryApp(company.id, setupInput, { actorType: "user", actorId: "alice" });
|
||||
const [connection] = await db.select().from(toolConnections).where(eq(toolConnections.id, connected.connectionId));
|
||||
const [grant] = await db.select().from(connectionGrants).where(eq(connectionGrants.connectionId, connected.connectionId));
|
||||
const [secret] = await db.select().from(companySecrets).where(eq(companySecrets.companyId, company.id));
|
||||
let activeSecretId = secret?.id;
|
||||
if (kind !== "public") {
|
||||
expect(secret).toMatchObject({ scope: grantKind === "user" ? "user" : "company", ownerUserId: grantKind === "user" ? "alice" : null });
|
||||
const expectedPath = kind === "header" ? "headers.X-Api-Key" : kind === "bearer" ? "credentials.authorization" : "remote.url";
|
||||
expect(grant!.credentialSecretRefs[0]!.configPath).toBe(expectedPath);
|
||||
const declarations = await db.select().from(userSecretDeclarations).where(eq(userSecretDeclarations.targetId, connection!.id));
|
||||
const bindings = await db.select().from(companySecretBindings).where(eq(companySecretBindings.targetId, connection!.id));
|
||||
expect(grantKind === "user" ? declarations : bindings).toEqual([expect.objectContaining({ configPath: expectedPath })]);
|
||||
expect(grantKind === "user" ? bindings : declarations).toEqual([]);
|
||||
} else expect(secret).toBeUndefined();
|
||||
await service.finishGalleryAppConnection(company.id, connected.connectionId, {
|
||||
enabledCatalogEntryIds: connected.catalog.map((entry) => entry.id),
|
||||
askFirstCatalogEntryIds: [], access: { agentIds: [agent.id] },
|
||||
}, { actorType: "user", actorId: "alice" });
|
||||
await allowAllToolsForAgent(db, company.id, agent.id);
|
||||
const gateway = createTestToolGatewayService(db, options);
|
||||
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
||||
const tools = (await gateway.listToolsForSession(session.token)).filter((tool) => tool.providerType === "mcp_remote_http");
|
||||
expect(tools).toHaveLength(2);
|
||||
for (const tool of tools) {
|
||||
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} }))
|
||||
.resolves.toMatchObject({ status: "completed", result: { content: "performed" } });
|
||||
}
|
||||
for (const call of calls.filter((call) => call.method === "tools/call")) {
|
||||
if (kind === "zapier" || kind === "url") expect(call.url).toBe(secretUrl);
|
||||
if (kind === "bearer") expect(call.headers.get("authorization")).toBe("Bearer fixture-canary");
|
||||
if (kind === "header") expect(call.headers.get("x-api-key")).toBe("fixture-canary");
|
||||
}
|
||||
expect(JSON.stringify(await db.select().from(toolAccessAuditEvents))).not.toContain("fixture-canary");
|
||||
if (grantKind === "user" && secret) {
|
||||
await expect(resolveConnectionGrantSecret(db, connection!, { ...grant!, subjectUserId: "bob" }, grant!.credentialSecretRefs[0]!, {}))
|
||||
.rejects.toMatchObject({ details: { code: "grant_credential_invalid" } });
|
||||
// Health must reject the exact legacy ownership mismatch that invocation rejects.
|
||||
await db.update(companySecrets).set({ scope: "company", ownerUserId: null, userSecretDefinitionId: null }).where(eq(companySecrets.id, secret.id));
|
||||
await expect(service.checkHealth(connection!.id, { actorType: "user", actorId: "alice" }))
|
||||
.rejects.toMatchObject({ details: { code: "grant_credential_invalid", connection: { healthStatus: "missing_secret" } } });
|
||||
await expect(service.refreshCatalog(connection!.id, { actorType: "user", actorId: "alice" }))
|
||||
.rejects.toMatchObject({ details: { code: "grant_credential_invalid" } });
|
||||
const otherCompany = await createCompany(db);
|
||||
// The owner reconnects explicitly; no startup process adopts the old row.
|
||||
await db.update(connectionGrants).set({ updatedAt: sql`'2026-09-30T12:00:00.123456Z'::timestamptz` })
|
||||
.where(eq(connectionGrants.id, grant!.id));
|
||||
const freshValue = "reconnected-fixture";
|
||||
if (reconnectMode === "inline") {
|
||||
if (kind === "url" || kind === "zapier") {
|
||||
await expect(service.reconnectGalleryApp(connection!.id, company.id, {
|
||||
credentialValues: { "remote.url": "https://8.8.8.8/different-endpoint?token=rejected-fixture" },
|
||||
}, { actorType: "user", actorId: "alice" }))
|
||||
.rejects.toMatchObject({ details: { code: "mcp_remote_url_credential_mismatch" } });
|
||||
}
|
||||
const configPath = kind === "url" || kind === "zapier" ? "remote.url" : kind === "header" ? "headers.X-Api-Key" : "credentials.authorization";
|
||||
const reconnected = await service.reconnectGalleryApp(connection!.id, company.id, {
|
||||
credentialValues: { [configPath]: kind === "url" || kind === "zapier" ? secretUrl.replace("fixture-canary", freshValue) : freshValue },
|
||||
}, { actorType: "user", actorId: "alice" });
|
||||
expect(reconnected.connection.id).toBe(connection!.id);
|
||||
expect(reconnected.connection.healthStatus).toBe("ok");
|
||||
} else {
|
||||
const reconnected = await service.connectGalleryApp(company.id, {
|
||||
...setupInput, reconnectConnectionId: connection!.id,
|
||||
...(kind === "zapier" || kind === "url" ? { link: secretUrl.replace("fixture-canary", freshValue) }
|
||||
: { credentialValues: { [kind === "header" ? "headers.X-Api-Key" : "credentials.authorization"]: freshValue } }),
|
||||
}, { actorType: "user", actorId: "alice" });
|
||||
expect(reconnected.connectionId).toBe(connection!.id);
|
||||
await service.finishGalleryAppConnection(company.id, reconnected.connectionId, {
|
||||
enabledCatalogEntryIds: reconnected.catalog.map((entry) => entry.id),
|
||||
askFirstCatalogEntryIds: [], access: { agentIds: [agent.id] }, preserveExistingAccess: true,
|
||||
}, { actorType: "user", actorId: "alice" });
|
||||
}
|
||||
const [restoredGrant] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, grant!.id));
|
||||
expect(restoredGrant).toMatchObject({ id: grant!.id, kind: "user", subjectUserId: "alice", status: "active" });
|
||||
activeSecretId = restoredGrant!.credentialSecretRefs[0]!.secretId;
|
||||
expect(activeSecretId).not.toBe(secret.id);
|
||||
const [freshSecret] = await db.select().from(companySecrets).where(eq(companySecrets.id, activeSecretId));
|
||||
expect(freshSecret).toMatchObject({ scope: "user", ownerUserId: "alice" });
|
||||
await expect(resolveConnectionGrantSecret(db, { ...connection!, companyId: otherCompany.id }, restoredGrant!, restoredGrant!.credentialSecretRefs[0]!, {}))
|
||||
.rejects.toMatchObject({ details: { code: "grant_credential_invalid" } });
|
||||
expect(await db.select().from(userSecretDeclarations).where(eq(userSecretDeclarations.targetId, connection!.id)))
|
||||
.toEqual([expect.objectContaining({ userSecretDefinitionId: freshSecret!.userSecretDefinitionId,
|
||||
configPath: restoredGrant!.credentialSecretRefs[0]!.configPath })]);
|
||||
expect(await db.select().from(companySecretBindings).where(eq(companySecretBindings.targetId, connection!.id))).toEqual([]);
|
||||
expect((await db.select().from(companySecrets).where(eq(companySecrets.id, secret.id)))[0])
|
||||
.toMatchObject({ scope: "company", ownerUserId: null, userSecretDefinitionId: null });
|
||||
calls.length = 0;
|
||||
const restoredSession = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
||||
const restoredTools = (await gateway.listToolsForSession(restoredSession.token)).filter((tool) => tool.providerType === "mcp_remote_http");
|
||||
expect(restoredTools).toHaveLength(2);
|
||||
for (const tool of restoredTools) {
|
||||
await expect(gateway.executeTool({ sessionToken: restoredSession.token, tool: tool.name, parameters: { verification: "after-reconnect" } }))
|
||||
.resolves.toMatchObject({ status: "completed", result: { content: "performed" } });
|
||||
}
|
||||
const restoredCalls = calls.filter((call) => call.method === "tools/call");
|
||||
expect(restoredCalls).toHaveLength(2);
|
||||
for (const call of restoredCalls) {
|
||||
if (kind === "zapier" || kind === "url") expect(call.url).toBe(secretUrl.replace("fixture-canary", freshValue));
|
||||
if (kind === "bearer") expect(call.headers.get("authorization")).toBe(`Bearer ${freshValue}`);
|
||||
if (kind === "header") expect(call.headers.get("x-api-key")).toBe(freshValue);
|
||||
}
|
||||
}
|
||||
if (secret) {
|
||||
const removed = await service.archiveConnection(connection!.id, company.id, { actorType: "user", actorId: "alice" });
|
||||
expect(removed.removal.secretsRevoked).toBe(1);
|
||||
const [deleted] = await db.select().from(companySecrets).where(eq(companySecrets.id, activeSecretId!));
|
||||
expect(deleted?.status ?? "deleted").toBe("deleted");
|
||||
}
|
||||
}
|
||||
}, 30_000,
|
||||
);
|
||||
|
||||
it.each(["header", "json"] as const)("returns an actionable insufficient-scope error from %s without exposing provider text", async (format) => {
|
||||
const company = await createCompany(db);
|
||||
const agent = await createAgent(db, company.id);
|
||||
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
||||
const { connection } = await createRemoteMcpTool(db, company.id, { url: "https://8.8.8.8/mcp", riskLevel: "write" });
|
||||
await allowAllToolsForAgent(db, company.id, agent.id);
|
||||
const gateway = createTestToolGatewayService(db, {
|
||||
remoteHttpEndpointLookup: async () => [{ address: "8.8.8.8", family: 4 }],
|
||||
remoteHttpRequest: async () => Response.json({ error: "insufficient_scope", message: "provider-secret-canary" }, {
|
||||
status: 403, headers: format === "header" ? { "www-authenticate": 'Bearer error="insufficient_scope"' } : {},
|
||||
}),
|
||||
});
|
||||
const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id });
|
||||
const tool = (await gateway.listToolsForSession(session.token)).find((entry) => entry.providerType === "mcp_remote_http")!;
|
||||
await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: { key: "key", value: "value" } }))
|
||||
.rejects.toMatchObject({ status: 403, reasonCode: "oauth_insufficient_scope", message: expect.stringContaining("Reconnect") });
|
||||
expect(JSON.stringify(await db.select().from(toolAccessAuditEvents))).not.toContain("provider-secret-canary");
|
||||
const [updated] = await db.select().from(toolConnections).where(eq(toolConnections.id, connection.id));
|
||||
expect(updated!.healthStatus).toBe("degraded");
|
||||
});
|
||||
|
||||
it("uses the responsible user's identity directly and requires delegation only without one", async () => {
|
||||
const company = await createCompany(db);
|
||||
const agent = await createAgent(db, company.id);
|
||||
|
||||
@@ -2,7 +2,7 @@ import { and, eq, inArray } from "drizzle-orm";
|
||||
import { type Db, toolConnections, companySecretBindings, connectionGrants, companySecrets, userSecretDefinitions } from "@paperclipai/db";
|
||||
import type { ToolCredentialSecretRef } from "@paperclipai/shared";
|
||||
import { secretService } from "./secrets.js";
|
||||
function credentialRefConfigPath(ref: { name: string }): string { return ref.name.startsWith("credentials.") ? ref.name : `credentials.${ref.name}`; }
|
||||
import { connectionCredentialConfigPath as credentialRefConfigPath } from "./connection-credentials.js";
|
||||
export async function syncConnectionCredentialBindings(
|
||||
db: Db | Parameters<Parameters<Db["transaction"]>[0]>[0],
|
||||
connection: typeof toolConnections.$inferSelect,
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, eq, inArray, isNull, ne, or } from "drizzle-orm";
|
||||
import { companySecrets, companySecretProposals, managedAgentProfiles, routineTriggers, userSecretDefinitions, type connectionGrants, type toolConnections } from "@paperclipai/db";
|
||||
import type { ToolCredentialSecretRef } from "@paperclipai/shared";
|
||||
import { connectionCredentialConfigPath } from "@paperclipai/shared";
|
||||
import { unprocessable } from "../errors.js";
|
||||
import { secretService } from "./secrets.js";
|
||||
|
||||
export { connectionCredentialConfigPath } from "@paperclipai/shared";
|
||||
|
||||
type CredentialDb = Parameters<typeof secretService>[0];
|
||||
type SecretActor = Parameters<ReturnType<typeof secretService>["create"]>[2];
|
||||
type ConsumerContext = NonNullable<Parameters<ReturnType<typeof secretService>["resolveUserSecretValue"]>[2]>;
|
||||
|
||||
/** These consumers reference secrets directly, without connection bindings. */
|
||||
export async function connectionSecretsUsedByOtherConsumers(db: CredentialDb, secretIds: string[]) {
|
||||
if (!secretIds.length) return new Set<string>();
|
||||
const profiles = await db.select({ secretId: managedAgentProfiles.apiKeySecretId }).from(managedAgentProfiles)
|
||||
.where(inArray(managedAgentProfiles.apiKeySecretId, secretIds));
|
||||
const triggers = await db.select({ secretId: routineTriggers.secretId }).from(routineTriggers)
|
||||
.where(inArray(routineTriggers.secretId, secretIds));
|
||||
const proposals = await db.select({ secretId: companySecretProposals.secretId, createdSecretId: companySecretProposals.createdSecretId })
|
||||
.from(companySecretProposals).where(or(inArray(companySecretProposals.secretId, secretIds), inArray(companySecretProposals.createdSecretId, secretIds)));
|
||||
return new Set([...profiles, ...triggers, ...proposals, ...proposals.map((row) => ({ secretId: row.createdSecretId }))]
|
||||
.flatMap((row) => row.secretId ? [row.secretId] : []));
|
||||
}
|
||||
|
||||
export function connectionGrantCredentialRef(
|
||||
grant: Pick<typeof connectionGrants.$inferSelect, "credentialSecretRefs">,
|
||||
ref: { name: string },
|
||||
): ToolCredentialSecretRef | undefined {
|
||||
return grant.credentialSecretRefs.find((candidate) =>
|
||||
candidate.configPath === ref.name || candidate.configPath === connectionCredentialConfigPath(ref),
|
||||
);
|
||||
}
|
||||
|
||||
/** Create/replace invocation credentials in the selected identity's vault. */
|
||||
export async function writeConnectionCredential(
|
||||
db: CredentialDb,
|
||||
input: {
|
||||
companyId: string;
|
||||
connectionName: string;
|
||||
configPath: string;
|
||||
label: string;
|
||||
value: string;
|
||||
ownerUserId?: string | null;
|
||||
existingRef?: ToolCredentialSecretRef;
|
||||
/** OAuth shares one definition per connection/path, with one value per owner. */
|
||||
definitionKey?: string;
|
||||
actor?: SecretActor;
|
||||
},
|
||||
) {
|
||||
const vault = secretService(db);
|
||||
if (input.existingRef) {
|
||||
const [existing] = await db.select().from(companySecrets).where(and(
|
||||
eq(companySecrets.id, input.existingRef.secretId),
|
||||
eq(companySecrets.companyId, input.companyId),
|
||||
)).limit(1);
|
||||
if (existing?.scope === "user" && existing.ownerUserId !== input.ownerUserId) {
|
||||
throw unprocessable("Reconnect this connection as its credential owner.", { code: "grant_credential_invalid" });
|
||||
}
|
||||
if (existing?.status === "active" && (input.ownerUserId
|
||||
? existing.scope === "user" && Boolean(existing.userSecretDefinitionId)
|
||||
: existing.scope === "company")) {
|
||||
const secret = input.ownerUserId
|
||||
? await vault.rotateCurrentUserSecretValue(input.companyId, input.ownerUserId, existing.id, { value: input.value }, input.actor)
|
||||
: await vault.rotate(existing.id, { value: input.value }, input.actor);
|
||||
return { secret, created: false, definitionId: null };
|
||||
}
|
||||
// Re-entering a credential is fresh consent. Replace a legacy company
|
||||
// secret with a new owner value; never rotate or adopt the old shared row.
|
||||
}
|
||||
const metadata = {
|
||||
name: `${input.connectionName} ${input.label} ${randomUUID().slice(0, 8)}`,
|
||||
key: `tool_app.${randomUUID()}.${input.configPath.replace(/[^a-z0-9_:-]+/gi, "_")}`,
|
||||
provider: "local_encrypted" as const,
|
||||
description: `Credential for ${input.connectionName} (${input.configPath}).`,
|
||||
};
|
||||
if (!input.ownerUserId) {
|
||||
return { secret: await vault.create(input.companyId, { ...metadata, value: input.value }, input.actor), created: true, definitionId: null };
|
||||
}
|
||||
let definition = input.definitionKey ? (await db.select().from(userSecretDefinitions).where(and(
|
||||
eq(userSecretDefinitions.companyId, input.companyId), eq(userSecretDefinitions.key, input.definitionKey),
|
||||
isNull(userSecretDefinitions.deletedAt),
|
||||
)).limit(1))[0] : undefined;
|
||||
let createdDefinitionId: string | null = null;
|
||||
if (!definition) {
|
||||
if (input.definitionKey) {
|
||||
[definition] = await db.insert(userSecretDefinitions).values({
|
||||
...metadata, key: input.definitionKey, companyId: input.companyId,
|
||||
managedMode: "paperclip_managed", createdByUserId: input.actor?.userId, createdByAgentId: input.actor?.agentId,
|
||||
}).onConflictDoNothing().returning();
|
||||
if (definition) createdDefinitionId = definition.id;
|
||||
else [definition] = await db.select().from(userSecretDefinitions).where(and(
|
||||
eq(userSecretDefinitions.companyId, input.companyId), eq(userSecretDefinitions.key, input.definitionKey),
|
||||
isNull(userSecretDefinitions.deletedAt),
|
||||
)).limit(1);
|
||||
} else {
|
||||
definition = await vault.createUserSecretDefinition(input.companyId, metadata, input.actor);
|
||||
createdDefinitionId = definition.id;
|
||||
}
|
||||
}
|
||||
if (!definition) throw unprocessable("Reconnect this connection to restore its credential definition.", { code: "grant_credential_invalid" });
|
||||
const [ownerValue] = await db.select().from(companySecrets).where(and(
|
||||
eq(companySecrets.companyId, input.companyId), eq(companySecrets.scope, "user"),
|
||||
eq(companySecrets.ownerUserId, input.ownerUserId), eq(companySecrets.userSecretDefinitionId, definition.id),
|
||||
ne(companySecrets.status, "deleted"),
|
||||
)).limit(1);
|
||||
if (ownerValue) {
|
||||
if (ownerValue.status !== "active") await vault.updateCurrentUserSecretValue(input.companyId, input.ownerUserId, ownerValue.id,
|
||||
{ status: "active" }, input.actor);
|
||||
return { secret: await vault.rotateCurrentUserSecretValue(input.companyId, input.ownerUserId, ownerValue.id,
|
||||
{ value: input.value }, input.actor), created: false, definitionId: createdDefinitionId };
|
||||
}
|
||||
const secret = await vault.createCurrentUserSecretValue(input.companyId, input.ownerUserId,
|
||||
{ definitionId: definition.id, value: input.value }, input.actor);
|
||||
return { secret, created: true, definitionId: createdDefinitionId };
|
||||
}
|
||||
|
||||
/** Validate metadata without reading secret values, including for connection pickers. */
|
||||
export async function validateConnectionGrantSecretOwnership(
|
||||
db: CredentialDb,
|
||||
connection: Pick<typeof toolConnections.$inferSelect, "id" | "companyId">,
|
||||
grant: Pick<typeof connectionGrants.$inferSelect, "id" | "companyId" | "connectionId" | "kind" | "subjectUserId">,
|
||||
ref: ToolCredentialSecretRef,
|
||||
) {
|
||||
const [secret] = await db.select().from(companySecrets).where(and(
|
||||
eq(companySecrets.id, ref.secretId), eq(companySecrets.companyId, connection.companyId),
|
||||
)).limit(1);
|
||||
const personal = grant.kind === "user";
|
||||
if (grant.companyId !== connection.companyId || grant.connectionId !== connection.id || !secret
|
||||
|| (personal
|
||||
? !grant.subjectUserId || secret.scope !== "user" || secret.ownerUserId !== grant.subjectUserId || !secret.userSecretDefinitionId
|
||||
: secret.scope !== "company")) {
|
||||
throw unprocessable("This connection's credential does not belong to the selected identity. Its owner must reconnect it.", {
|
||||
code: "grant_credential_invalid", connectionId: connection.id, grantId: grant.id, credential: ref.configPath,
|
||||
});
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
|
||||
/** The same owner/declaration checks apply during setup, testing and execution. */
|
||||
export async function resolveConnectionGrantSecret(
|
||||
db: CredentialDb,
|
||||
connection: Pick<typeof toolConnections.$inferSelect, "id" | "companyId">,
|
||||
grant: Pick<typeof connectionGrants.$inferSelect, "id" | "companyId" | "connectionId" | "kind" | "subjectUserId">,
|
||||
ref: ToolCredentialSecretRef,
|
||||
context: ConsumerContext,
|
||||
) {
|
||||
const secret = await validateConnectionGrantSecretOwnership(db, connection, grant, ref);
|
||||
const personal = grant.kind === "user";
|
||||
const accessContext = { ...context, consumerType: "tool_connection" as const, consumerId: connection.id,
|
||||
configPath: ref.configPath, responsibleUserId: grant.subjectUserId };
|
||||
const vault = secretService(db);
|
||||
if (!personal) {
|
||||
return { value: await vault.resolveSecretValue(connection.companyId, ref.secretId, ref.versionSelector ?? "latest",
|
||||
{ accessContext }), latestVersion: secret.latestVersion };
|
||||
}
|
||||
const resolved = await vault.resolveUserSecretValue(connection.companyId, {
|
||||
definitionId: secret.userSecretDefinitionId!, responsibleUserId: grant.subjectUserId!,
|
||||
version: ref.versionSelector ?? "latest", required: ref.required ?? true,
|
||||
}, accessContext);
|
||||
if (!resolved) throw unprocessable("Your credential is missing. Reconnect this connection.", {
|
||||
code: "user_secret_missing", connectionId: connection.id, grantId: grant.id, credential: ref.configPath,
|
||||
});
|
||||
return { value: resolved.value, latestVersion: secret.latestVersion };
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/** Only explicit protocol error codes establish insufficient consent. Never echo provider bodies. */
|
||||
export function isInsufficientConnectionScope(response: Pick<Response, "status" | "headers">, body?: string): boolean {
|
||||
if (response.status !== 401 && response.status !== 403) return false;
|
||||
if (/\berror\s*=\s*"?insufficient_scope\b/i.test(response.headers.get("www-authenticate") ?? "")) return true;
|
||||
if (!body) return false;
|
||||
try {
|
||||
const payload = JSON.parse(body);
|
||||
return payload?.error === "insufficient_scope" || payload?.error?.code === "insufficient_scope"
|
||||
|| payload?.error?.data?.code === "insufficient_scope";
|
||||
} catch { return false; }
|
||||
}
|
||||
export const INSUFFICIENT_CONNECTION_SCOPE_MESSAGE = "The provider has not granted the permissions needed for this action. Reconnect this connection and allow the required read and write access.";
|
||||
+171
-495
@@ -1,3 +1,4 @@
|
||||
import { isInsufficientConnectionScope, INSUFFICIENT_CONNECTION_SCOPE_MESSAGE } from "./connection-permission-errors.js";
|
||||
import { BROWSER_USE_TOOLS } from "@paperclipai/shared";
|
||||
import { browserUseClient, isBrowserUseConnection } from "./browser-use-client.js";
|
||||
import { browserUseService } from "./browser-use.js";
|
||||
@@ -44,6 +45,7 @@ import {
|
||||
companySecrets,
|
||||
principalPermissionGrants,
|
||||
userSecretDefinitions,
|
||||
userSecretDeclarations,
|
||||
heartbeatRuns,
|
||||
issues,
|
||||
issueThreadInteractions,
|
||||
@@ -219,6 +221,7 @@ import {
|
||||
splitRemoteUrlCredential,
|
||||
} from "./remote-url-credentials.js";
|
||||
import { secretService } from "./secrets.js";
|
||||
import { connectionCredentialConfigPath as credentialRefConfigPath, connectionGrantCredentialRef, connectionSecretsUsedByOtherConsumers, resolveConnectionGrantSecret, validateConnectionGrantSecretOwnership, writeConnectionCredential } from "./connection-credentials.js";
|
||||
import { agentmailApi } from "./agentmail-api.js";
|
||||
import type { ConfigureRailwaySsh, RailwaySshSetup } from "@paperclipai/shared";
|
||||
import { generateRailwaySshKey, RAILWAY_SSH_SECRET_PATH, validateRailwayKnownHosts } from "./railway-ssh.js";
|
||||
@@ -1057,11 +1060,6 @@ function credentialFieldsFor(app: AppDefinition, methodKey?: string | null) {
|
||||
}));
|
||||
}
|
||||
|
||||
function credentialRefConfigPath(ref: { name: string }): string {
|
||||
return ref.name.startsWith("credentials.")
|
||||
? ref.name
|
||||
: `credentials.${ref.name}`;
|
||||
}
|
||||
|
||||
export function normalizeConnectionMethodConfig(
|
||||
method: ConnectionMethodDef,
|
||||
@@ -2866,6 +2864,9 @@ function sanitizeHttpFailure(error: unknown): {
|
||||
if (code === "cognee_access_unverified" || code === "memory_api_key_rejected") {
|
||||
return { status: "error", message: error.message, code };
|
||||
}
|
||||
if (code === "grant_credential_invalid" || code === "oauth_insufficient_scope") {
|
||||
return { status: code === "grant_credential_invalid" ? "missing_secret" : "degraded", message: error.message, code };
|
||||
}
|
||||
if (code === "slack_mcp_access_disabled") {
|
||||
return { status: "error", message: error.message, code };
|
||||
}
|
||||
@@ -3002,9 +3003,20 @@ async function gitHubReadGrantAccess(db: Db, companyId: string, connectionId: st
|
||||
if (!localTrusted && (!membership || membership.membershipRole === "viewer")) throw forbidden("GitHub access requires an active company member.");
|
||||
const grants = await db.select().from(connectionGrants).where(and(eq(connectionGrants.companyId, companyId), eq(connectionGrants.connectionId, connectionId)));
|
||||
const members = await db.select().from(connectionGrantMembers).where(eq(connectionGrantMembers.companyId, companyId));
|
||||
const allowed = grants.filter(grant => !(grant.kind === "organization" && ["per_user", "per_agent"].includes(connection.credentialPolicy)) && canBrowseProjectRepositoryGrant({
|
||||
const candidates = grants.filter(grant => !(grant.kind === "organization" && ["per_user", "per_agent"].includes(connection.credentialPolicy)) && canBrowseProjectRepositoryGrant({
|
||||
grant, userId, activeMember: localTrusted || Boolean(membership), audience: members.filter(member => member.grantId === grant.id).map(member => member.subjectId),
|
||||
}));
|
||||
const allowed: typeof candidates = [];
|
||||
for (const grant of candidates) {
|
||||
const ref = grant.credentialSecretRefs.find(ref => ref.configPath === "oauth.access_token" || /authorization|token|api_key/i.test(ref.configPath));
|
||||
if (!ref) continue;
|
||||
try {
|
||||
await validateConnectionGrantSecretOwnership(db, connection, grant, ref);
|
||||
allowed.push(grant);
|
||||
} catch (error) {
|
||||
if (!(error instanceof HttpError && asRecord(error.details).code === "grant_credential_invalid")) throw error;
|
||||
}
|
||||
}
|
||||
const legacyShared = !grants.length && connection.credentialPolicy === "shared";
|
||||
if (!allowed.length && !legacyShared) throw forbidden("Choose a GitHub connection with an active authorization you can use.");
|
||||
return { connection, allowed, legacyShared };
|
||||
@@ -3027,27 +3039,23 @@ export function toolAccessService(
|
||||
if (!ref) return publicEndpoint;
|
||||
let value: string;
|
||||
try {
|
||||
value = await secrets.resolveSecretValue(
|
||||
connection.companyId,
|
||||
ref.secretId,
|
||||
ref.version ?? "latest",
|
||||
{
|
||||
consumerType: "tool_connection",
|
||||
consumerId: connection.id,
|
||||
configPath: REMOTE_URL_SECRET_CONFIG_PATH,
|
||||
actorType: actor?.actorType ?? "system",
|
||||
actorId: actor?.actorId ?? null,
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
throw unprocessable(
|
||||
"A configured credential secret could not be resolved.",
|
||||
{
|
||||
code: "mcp_remote_missing_secret",
|
||||
connectionId: connection.id,
|
||||
credential: REMOTE_URL_SECRET_CONFIG_PATH,
|
||||
},
|
||||
);
|
||||
const grant = await vaultGrantForConnection(connection, actor);
|
||||
const grantRef = grant ? connectionGrantCredentialRef(grant, ref) : undefined;
|
||||
if (grant && !grantRef) throw unprocessable("Your MCP URL credential is missing. Reconnect this connection.", {
|
||||
code: "grant_credential_invalid", connectionId: connection.id, grantId: grant.id,
|
||||
});
|
||||
value = grant && grantRef
|
||||
? (await resolveOAuthGrantSecret(connection, grant, grantRef, actor, undefined)).value
|
||||
: await secrets.resolveSecretValue(connection.companyId, ref.secretId, ref.version ?? "latest", {
|
||||
consumerType: "tool_connection", consumerId: connection.id,
|
||||
configPath: REMOTE_URL_SECRET_CONFIG_PATH,
|
||||
actorType: actor?.actorType ?? "system", actorId: actor?.actorId ?? null,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError) throw error;
|
||||
throw unprocessable("Your MCP URL credential could not be resolved. Reconnect this connection.", {
|
||||
code: "mcp_remote_missing_secret", connectionId: connection.id, credential: REMOTE_URL_SECRET_CONFIG_PATH,
|
||||
});
|
||||
}
|
||||
if (!remoteUrlCredentialMatchesPublicUrl(publicEndpoint, value)) {
|
||||
throw unprocessable(
|
||||
@@ -5843,150 +5851,7 @@ export function toolAccessService(
|
||||
grantSecretRefs: ToolCredentialSecretRef[] = [],
|
||||
dbClient: ToolAccessMutationDb = db,
|
||||
) {
|
||||
await dbClient
|
||||
.delete(companySecretBindings)
|
||||
.where(
|
||||
and(
|
||||
eq(companySecretBindings.companyId, connection.companyId),
|
||||
eq(companySecretBindings.targetType, "tool_connection"),
|
||||
eq(companySecretBindings.targetId, connection.id),
|
||||
),
|
||||
);
|
||||
// A metadata edit or pause/resume must retain declarations for every
|
||||
// active personal/dedicated grant, not just connection-owned credentials.
|
||||
const activeGrants = await dbClient
|
||||
.select({ refs: connectionGrants.credentialSecretRefs })
|
||||
.from(connectionGrants)
|
||||
.where(
|
||||
and(
|
||||
eq(connectionGrants.companyId, connection.companyId),
|
||||
eq(connectionGrants.connectionId, connection.id),
|
||||
eq(connectionGrants.status, "active"),
|
||||
),
|
||||
);
|
||||
const rawBindings = [
|
||||
...connection.credentialRefs.map((ref) => ({
|
||||
secretId: ref.secretId,
|
||||
configPath: credentialRefConfigPath(ref),
|
||||
projectionClass: "unclassified",
|
||||
projectionAllowlistKey: null,
|
||||
required: true,
|
||||
label: null,
|
||||
})),
|
||||
...[
|
||||
...connection.credentialSecretRefs,
|
||||
...grantSecretRefs,
|
||||
...activeGrants.flatMap((grant) => grant.refs),
|
||||
].map((ref) => ({
|
||||
secretId: ref.secretId,
|
||||
configPath: ref.configPath,
|
||||
projectionClass: ref.projectionClass ?? "unclassified",
|
||||
projectionAllowlistKey: ref.projectionAllowlistKey ?? null,
|
||||
required: ref.required ?? true,
|
||||
label: ref.label ?? null,
|
||||
})),
|
||||
];
|
||||
// Organization grants can mirror connection-owned credentials, and more
|
||||
// than one personal grant can reference the same client registration.
|
||||
// Binding rows are unique per secret/config path, so collapse those mirrors
|
||||
// before replacing the durable projection declarations.
|
||||
const bindings = [
|
||||
...new Map(
|
||||
rawBindings.map((ref) => [`${ref.secretId}:${ref.configPath}`, ref]),
|
||||
).values(),
|
||||
];
|
||||
const secretRows =
|
||||
bindings.length > 0
|
||||
? await dbClient
|
||||
.select({
|
||||
id: companySecrets.id,
|
||||
scope: companySecrets.scope,
|
||||
userSecretDefinitionId: companySecrets.userSecretDefinitionId,
|
||||
})
|
||||
.from(companySecrets)
|
||||
.where(
|
||||
and(
|
||||
eq(companySecrets.companyId, connection.companyId),
|
||||
inArray(companySecrets.id, [
|
||||
...new Set(bindings.map((ref) => ref.secretId)),
|
||||
]),
|
||||
),
|
||||
)
|
||||
: [];
|
||||
const secretById = new Map(secretRows.map((row) => [row.id, row]));
|
||||
const definitionIds = [
|
||||
...new Set(
|
||||
secretRows.flatMap((row) =>
|
||||
row.userSecretDefinitionId ? [row.userSecretDefinitionId] : [],
|
||||
),
|
||||
),
|
||||
];
|
||||
const definitions =
|
||||
definitionIds.length > 0
|
||||
? await dbClient
|
||||
.select({
|
||||
id: userSecretDefinitions.id,
|
||||
key: userSecretDefinitions.key,
|
||||
})
|
||||
.from(userSecretDefinitions)
|
||||
.where(
|
||||
and(
|
||||
eq(userSecretDefinitions.companyId, connection.companyId),
|
||||
inArray(userSecretDefinitions.id, definitionIds),
|
||||
),
|
||||
)
|
||||
: [];
|
||||
const definitionKeyById = new Map(
|
||||
definitions.map((row) => [row.id, row.key]),
|
||||
);
|
||||
const userDeclarations = [
|
||||
...new Map(
|
||||
bindings
|
||||
.flatMap((ref) => {
|
||||
const secret = secretById.get(ref.secretId);
|
||||
const definitionKey =
|
||||
secret?.scope === "user" && secret.userSecretDefinitionId
|
||||
? definitionKeyById.get(secret.userSecretDefinitionId)
|
||||
: null;
|
||||
return definitionKey
|
||||
? [
|
||||
{
|
||||
definitionKey,
|
||||
configPath: ref.configPath,
|
||||
envKey: ref.configPath,
|
||||
versionSelector: "latest" as const,
|
||||
required: ref.required,
|
||||
label: ref.label,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
})
|
||||
.map((ref) => [`${ref.definitionKey}:${ref.configPath}`, ref]),
|
||||
).values(),
|
||||
];
|
||||
await secrets.syncUserSecretDeclarationsForTarget(
|
||||
connection.companyId,
|
||||
{ targetType: "tool_connection", targetId: connection.id },
|
||||
userDeclarations,
|
||||
{ replaceAll: true, db: dbClient },
|
||||
);
|
||||
const companyBindings = bindings.filter(
|
||||
(ref) => secretById.get(ref.secretId)?.scope !== "user",
|
||||
);
|
||||
if (companyBindings.length === 0) return;
|
||||
await dbClient.insert(companySecretBindings).values(
|
||||
companyBindings.map((ref) => ({
|
||||
companyId: connection.companyId,
|
||||
secretId: ref.secretId,
|
||||
targetType: "tool_connection" as const,
|
||||
targetId: connection.id,
|
||||
configPath: ref.configPath,
|
||||
required: ref.required,
|
||||
label: ref.label,
|
||||
projectionClass: ref.projectionClass,
|
||||
projectionAllowlistKey: ref.projectionAllowlistKey,
|
||||
})),
|
||||
);
|
||||
await syncConnectionCredentialBindings(db, connection, grantSecretRefs, dbClient);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -5998,8 +5863,8 @@ export function toolAccessService(
|
||||
* target. Two independent tests have to agree before a secret is destroyed:
|
||||
*
|
||||
* 1. Provenance — the key sits in the `tool_app.` namespace only the
|
||||
* connect/reconnect/OAuth paths mint, and the row is a company-scoped
|
||||
* Paperclip secret rather than a per-user credential.
|
||||
* connect/reconnect/OAuth paths mint, or its user-secret definition does.
|
||||
* Personal definitions must also have no declarations on other targets.
|
||||
* 2. Exclusivity — nothing outside this connection references it: no
|
||||
* `company_secret_bindings` row from another target, and no other
|
||||
* connection or connection grant naming the same secret id.
|
||||
@@ -6037,7 +5902,7 @@ export function toolAccessService(
|
||||
);
|
||||
const byId = new Map(secretRows.map((row) => [row.id, row]));
|
||||
|
||||
const referencedElsewhere = new Set<string>();
|
||||
const referencedElsewhere = await connectionSecretsUsedByOtherConsumers(db, unique);
|
||||
const foreignBindings = await db
|
||||
.select({ secretId: companySecretBindings.secretId })
|
||||
.from(companySecretBindings)
|
||||
@@ -6085,6 +5950,15 @@ export function toolAccessService(
|
||||
referencedElsewhere.add(ref.secretId);
|
||||
}
|
||||
|
||||
const definitionIds = secretRows.flatMap((row) => row.userSecretDefinitionId ? [row.userSecretDefinitionId] : []);
|
||||
const definitions = definitionIds.length ? await db.select().from(userSecretDefinitions)
|
||||
.where(and(eq(userSecretDefinitions.companyId, connection.companyId), inArray(userSecretDefinitions.id, definitionIds))) : [];
|
||||
const dedicatedDefinitions = new Set(definitions.filter((row) => row.key.startsWith(CONNECTION_OWNED_SECRET_KEY_PREFIX) || row.key.startsWith(`tool_oauth.${connection.id}.`)).map((row) => row.id));
|
||||
const foreignDeclarations = definitionIds.length ? await db.select().from(userSecretDeclarations).where(and(
|
||||
inArray(userSecretDeclarations.userSecretDefinitionId, definitionIds),
|
||||
sql`not (${userSecretDeclarations.targetType} = 'tool_connection' and ${userSecretDeclarations.targetId} = ${connection.id})`,
|
||||
)) : [];
|
||||
const sharedDefinitions = new Set(foreignDeclarations.map((row) => row.userSecretDefinitionId));
|
||||
const owned: string[] = [];
|
||||
const retained: string[] = [];
|
||||
for (const secretId of unique) {
|
||||
@@ -6096,10 +5970,10 @@ export function toolAccessService(
|
||||
owned.push(secretId);
|
||||
continue;
|
||||
}
|
||||
const dedicated =
|
||||
row.scope === "company" &&
|
||||
row.userSecretDefinitionId === null &&
|
||||
row.key.startsWith(CONNECTION_OWNED_SECRET_KEY_PREFIX);
|
||||
const dedicated = row.scope === "user" && row.userSecretDefinitionId
|
||||
? dedicatedDefinitions.has(row.userSecretDefinitionId) && !sharedDefinitions.has(row.userSecretDefinitionId)
|
||||
: row.scope === "company" && row.userSecretDefinitionId === null
|
||||
&& row.key.startsWith(CONNECTION_OWNED_SECRET_KEY_PREFIX);
|
||||
if (dedicated && !referencedElsewhere.has(secretId)) owned.push(secretId);
|
||||
else retained.push(secretId);
|
||||
}
|
||||
@@ -6624,6 +6498,16 @@ export function toolAccessService(
|
||||
.limit(2);
|
||||
if (personalGrants.length === 1) return personalGrants[0]!;
|
||||
}
|
||||
if (connection.credentialPolicy === "per_user") {
|
||||
throw unprocessable(
|
||||
"This connection needs the current user's authorization",
|
||||
{
|
||||
code: "user_authorization_required",
|
||||
setupUrl: connectionSetupUrl(connection),
|
||||
reconnectUrl: connectionReconnectUrl(connection),
|
||||
},
|
||||
);
|
||||
}
|
||||
const [organization] = await db
|
||||
.select()
|
||||
.from(connectionGrants)
|
||||
@@ -6637,16 +6521,6 @@ export function toolAccessService(
|
||||
)
|
||||
.limit(1);
|
||||
if (organization?.status === "active") return organization;
|
||||
if (connection.credentialPolicy === "per_user") {
|
||||
throw unprocessable(
|
||||
"This connection needs the current user's authorization",
|
||||
{
|
||||
code: "user_authorization_required",
|
||||
setupUrl: connectionSetupUrl(connection),
|
||||
reconnectUrl: connectionReconnectUrl(connection),
|
||||
},
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -6703,12 +6577,14 @@ export function toolAccessService(
|
||||
const headers: Record<string, string> = {};
|
||||
const scope = credentialScope(connection);
|
||||
for (const ref of connection.credentialRefs) {
|
||||
if (ref.placement !== "header") continue;
|
||||
let value: string;
|
||||
const configPath = credentialRefConfigPath(ref);
|
||||
try {
|
||||
const grantRef = grant?.credentialSecretRefs.find(
|
||||
(candidate) => candidate.configPath === configPath,
|
||||
);
|
||||
const grantRef = grant ? connectionGrantCredentialRef(grant, ref) : undefined;
|
||||
if (grant && !grantRef) throw unprocessable("Your credential is missing. Reconnect this connection.", {
|
||||
code: "grant_credential_invalid", connectionId: connection.id, grantId: grant.id, credential: configPath,
|
||||
});
|
||||
value =
|
||||
grantRef && grant
|
||||
? (
|
||||
@@ -6876,6 +6752,10 @@ export function toolAccessService(
|
||||
),
|
||||
);
|
||||
}
|
||||
if (isInsufficientConnectionScope(response)) {
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
throw unprocessable(INSUFFICIENT_CONNECTION_SCOPE_MESSAGE, { code: "oauth_insufficient_scope", connectionId: connection.id });
|
||||
}
|
||||
if (
|
||||
response.status === 401 &&
|
||||
connection.credentialSource === "vercel_connect"
|
||||
@@ -9204,147 +9084,24 @@ export function toolAccessService(
|
||||
ownerUserId?: string;
|
||||
},
|
||||
context?: {
|
||||
dbClient: ToolAccessMutationDb;
|
||||
dbClient: Db | DbTransaction;
|
||||
secretClient: ReturnType<typeof secretService>;
|
||||
},
|
||||
) {
|
||||
const dbClient = context?.dbClient ?? db;
|
||||
const secretClient = context?.secretClient ?? secrets;
|
||||
const existing =
|
||||
input.existingRefs === undefined
|
||||
? oauthSecretRef(input.connection, input.configPath)
|
||||
: input.existingRefs.find((ref) => ref.configPath === input.configPath);
|
||||
if (existing) {
|
||||
await secretClient.rotate(
|
||||
existing.secretId,
|
||||
{ value: input.value },
|
||||
actorForSecret(input.actor),
|
||||
);
|
||||
return existing;
|
||||
}
|
||||
if (input.ownerUserId) {
|
||||
const definitionKey = `tool_oauth.${input.connection.id}.${input.configPath.replace(/[^a-z0-9_:-]+/gi, "_")}`;
|
||||
let [definition] = await dbClient
|
||||
.select()
|
||||
.from(userSecretDefinitions)
|
||||
.where(
|
||||
and(
|
||||
eq(userSecretDefinitions.companyId, input.companyId),
|
||||
eq(userSecretDefinitions.key, definitionKey),
|
||||
isNull(userSecretDefinitions.deletedAt),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!definition) {
|
||||
[definition] = await dbClient
|
||||
.insert(userSecretDefinitions)
|
||||
.values({
|
||||
companyId: input.companyId,
|
||||
key: definitionKey,
|
||||
name: `${input.connection.name} ${input.label}`,
|
||||
description: `Personal OAuth ${input.label.toLowerCase()} for ${input.connection.name}.`,
|
||||
provider: "local_encrypted",
|
||||
managedMode: "paperclip_managed",
|
||||
createdByAgentId:
|
||||
input.actor?.actorType === "agent" ? input.actor.actorId : null,
|
||||
createdByUserId:
|
||||
input.actor?.actorType === "user" ? input.actor.actorId : null,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
if (!definition) {
|
||||
[definition] = await dbClient
|
||||
.select()
|
||||
.from(userSecretDefinitions)
|
||||
.where(
|
||||
and(
|
||||
eq(userSecretDefinitions.companyId, input.companyId),
|
||||
eq(userSecretDefinitions.key, definitionKey),
|
||||
isNull(userSecretDefinitions.deletedAt),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
}
|
||||
}
|
||||
if (!definition)
|
||||
throw new Error("Failed to create personal OAuth secret definition");
|
||||
const [existingUserValue] = await dbClient
|
||||
.select()
|
||||
.from(companySecrets)
|
||||
.where(
|
||||
and(
|
||||
eq(companySecrets.companyId, input.companyId),
|
||||
eq(companySecrets.scope, "user"),
|
||||
eq(companySecrets.ownerUserId, input.ownerUserId),
|
||||
eq(companySecrets.userSecretDefinitionId, definition.id),
|
||||
ne(companySecrets.status, "deleted"),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (existingUserValue) {
|
||||
// A removed/revoked grant can predate credential cleanup and therefore
|
||||
// lose its ref while its deterministic owner value remains. Reconnect
|
||||
// is explicit fresh consent, so revive that owner-bound value and
|
||||
// rotate it instead of colliding with the one-value-per-definition
|
||||
// constraint.
|
||||
if (existingUserValue.status !== "active") {
|
||||
await secretClient.updateCurrentUserSecretValue(
|
||||
input.companyId,
|
||||
input.ownerUserId,
|
||||
existingUserValue.id,
|
||||
{ status: "active" },
|
||||
actorForSecret(input.actor),
|
||||
);
|
||||
}
|
||||
const secret = await secretClient.rotateCurrentUserSecretValue(
|
||||
input.companyId,
|
||||
input.ownerUserId,
|
||||
existingUserValue.id,
|
||||
{ value: input.value },
|
||||
actorForSecret(input.actor),
|
||||
);
|
||||
return {
|
||||
secretId: secret.id,
|
||||
versionSelector: "latest" as const,
|
||||
configPath: input.configPath,
|
||||
required: input.configPath === "oauth.access_token",
|
||||
label: input.label,
|
||||
};
|
||||
}
|
||||
const secret = await secretClient.createCurrentUserSecretValue(
|
||||
input.companyId,
|
||||
input.ownerUserId,
|
||||
{
|
||||
definitionId: definition.id,
|
||||
value: input.value,
|
||||
},
|
||||
actorForSecret(input.actor),
|
||||
);
|
||||
return {
|
||||
secretId: secret.id,
|
||||
versionSelector: "latest" as const,
|
||||
configPath: input.configPath,
|
||||
required: input.configPath === "oauth.access_token",
|
||||
label: input.label,
|
||||
};
|
||||
}
|
||||
const secret = await secretClient.create(
|
||||
input.companyId,
|
||||
{
|
||||
name: `${input.connection.name} ${input.label} ${randomUUID().slice(0, 8)}`,
|
||||
key: `tool_app.${randomUUID()}.${input.configPath.replace(/[^a-z0-9_:-]+/gi, "_")}`,
|
||||
provider: "local_encrypted",
|
||||
value: input.value,
|
||||
description: `OAuth ${input.label.toLowerCase()} for ${input.connection.name}.`,
|
||||
},
|
||||
actorForSecret(input.actor),
|
||||
);
|
||||
const existing = input.existingRefs === undefined
|
||||
? oauthSecretRef(input.connection, input.configPath)
|
||||
: input.existingRefs.find((ref) => ref.configPath === input.configPath);
|
||||
const write = (client: Db | DbTransaction) => writeConnectionCredential(client, {
|
||||
companyId: input.companyId, connectionName: input.connection.name, configPath: input.configPath,
|
||||
label: input.label, value: input.value, existingRef: existing ?? undefined, actor: actorForSecret(input.actor),
|
||||
// Registration authenticates the OAuth client, never the person invoking tools.
|
||||
ownerUserId: input.configPath === "oauth.client_secret" ? undefined : input.ownerUserId,
|
||||
definitionKey: input.ownerUserId ? `tool_oauth.${input.connection.id}.${input.configPath.replace(/[^a-z0-9_:-]+/gi, "_")}` : undefined,
|
||||
});
|
||||
const { secret } = context ? await write(context.dbClient) : await db.transaction(write);
|
||||
return {
|
||||
secretId: secret.id,
|
||||
versionSelector: "latest" as const,
|
||||
configPath: input.configPath,
|
||||
required: input.configPath === "oauth.access_token",
|
||||
label: input.label,
|
||||
secretId: secret.id, versionSelector: "latest" as const, configPath: input.configPath,
|
||||
required: input.configPath === "oauth.access_token", label: input.label,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10795,74 +10552,11 @@ export function toolAccessService(
|
||||
accessContext:
|
||||
{ issueId?: string | null; heartbeatRunId?: string | null } | undefined,
|
||||
) {
|
||||
const [secret] = await db
|
||||
.select({
|
||||
scope: companySecrets.scope,
|
||||
ownerUserId: companySecrets.ownerUserId,
|
||||
userSecretDefinitionId: companySecrets.userSecretDefinitionId,
|
||||
latestVersion: companySecrets.latestVersion,
|
||||
})
|
||||
.from(companySecrets)
|
||||
.where(
|
||||
and(
|
||||
eq(companySecrets.id, ref.secretId),
|
||||
eq(companySecrets.companyId, connection.companyId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!secret) throw notFound("OAuth credential secret not found");
|
||||
const consumerContext = {
|
||||
consumerType: "tool_connection" as const,
|
||||
consumerId: connection.id,
|
||||
configPath: ref.configPath,
|
||||
actorType: actor?.actorType ?? ("system" as const),
|
||||
actorId: actor?.actorId ?? null,
|
||||
responsibleUserId: grant.subjectUserId,
|
||||
issueId: accessContext?.issueId,
|
||||
heartbeatRunId: accessContext?.heartbeatRunId,
|
||||
};
|
||||
if (secret.scope !== "user") {
|
||||
return {
|
||||
value: await secrets.resolveSecretValue(
|
||||
connection.companyId,
|
||||
ref.secretId,
|
||||
ref.versionSelector ?? "latest",
|
||||
consumerContext,
|
||||
),
|
||||
latestVersion: secret.latestVersion,
|
||||
};
|
||||
}
|
||||
if (
|
||||
grant.kind !== "user" ||
|
||||
!grant.subjectUserId ||
|
||||
secret.ownerUserId !== grant.subjectUserId ||
|
||||
!secret.userSecretDefinitionId
|
||||
) {
|
||||
throw unprocessable("Personal authorization has an invalid credential", {
|
||||
code: "grant_credential_invalid",
|
||||
connectionId: connection.id,
|
||||
grantId: grant.id,
|
||||
credential: ref.configPath,
|
||||
});
|
||||
}
|
||||
const resolved = await secrets.resolveUserSecretValue(
|
||||
connection.companyId,
|
||||
{
|
||||
definitionId: secret.userSecretDefinitionId,
|
||||
responsibleUserId: grant.subjectUserId,
|
||||
version: ref.versionSelector ?? "latest",
|
||||
required: ref.required ?? true,
|
||||
},
|
||||
consumerContext,
|
||||
);
|
||||
if (!resolved)
|
||||
throw unprocessable("Personal OAuth credential is not configured", {
|
||||
code: "user_secret_missing",
|
||||
connectionId: connection.id,
|
||||
grantId: grant.id,
|
||||
credential: ref.configPath,
|
||||
});
|
||||
return { value: resolved.value, latestVersion: secret.latestVersion };
|
||||
return resolveConnectionGrantSecret(db, connection, grant, ref, {
|
||||
consumerType: "tool_connection", consumerId: connection.id,
|
||||
actorType: actor?.actorType ?? "system", actorId: actor?.actorId ?? null,
|
||||
issueId: accessContext?.issueId, heartbeatRunId: accessContext?.heartbeatRunId,
|
||||
});
|
||||
}
|
||||
|
||||
async function clearOAuthGrantRefreshLease(
|
||||
@@ -12589,28 +12283,13 @@ export function toolAccessService(
|
||||
throw badRequest(`Missing credential value for ${field.configPath}`);
|
||||
}
|
||||
if (!value) continue;
|
||||
const secretMetadata = {
|
||||
name: `${name} ${field.label} ${randomUUID().slice(0, 8)}`,
|
||||
key: `tool_app.${randomUUID()}.${field.configPath.replace(/[^a-z0-9_:-]+/gi, "_")}`,
|
||||
provider: "local_encrypted" as const,
|
||||
description: `Credential for ${name} (${field.configPath}).`,
|
||||
};
|
||||
// A personal grant must own a user-scoped vault value. Merely keeping a
|
||||
// company secret off the connection row does not establish ownership.
|
||||
const secret = personalIdentityUserId
|
||||
? await db.transaction(async (tx) => {
|
||||
const personalSecrets = secretService(tx as unknown as Db);
|
||||
const definition = await personalSecrets.createUserSecretDefinition(
|
||||
companyId, secretMetadata, actorForSecret(actor),
|
||||
);
|
||||
const valueRow = await personalSecrets.createCurrentUserSecretValue(
|
||||
companyId, personalIdentityUserId,
|
||||
{ definitionId: definition.id, value }, actorForSecret(actor),
|
||||
);
|
||||
createdDefinitionIds.push(definition.id);
|
||||
return valueRow;
|
||||
})
|
||||
: await secrets.create(companyId, { ...secretMetadata, value }, actorForSecret(actor));
|
||||
const written = await db.transaction((tx) => writeConnectionCredential(tx, {
|
||||
companyId, connectionName: name, configPath: field.configPath,
|
||||
label: field.label, value, ownerUserId: personalIdentityUserId,
|
||||
actor: actorForSecret(actor),
|
||||
}));
|
||||
const { secret } = written;
|
||||
if (written.definitionId) createdDefinitionIds.push(written.definitionId);
|
||||
createdSecretIds.push(secret.id);
|
||||
credentialSecretRefs.push({
|
||||
secretId: secret.id,
|
||||
@@ -12639,17 +12318,13 @@ export function toolAccessService(
|
||||
}
|
||||
}
|
||||
if (remoteUrlCredential?.secretUrl) {
|
||||
const secret = await secrets.create(
|
||||
companyId,
|
||||
{
|
||||
name: `${name} MCP server URL ${randomUUID().slice(0, 8)}`,
|
||||
key: `tool_app.${randomUUID()}.remote_url`,
|
||||
provider: "local_encrypted",
|
||||
value: remoteUrlCredential.secretUrl,
|
||||
description: `Credential-bearing MCP server URL for ${name}.`,
|
||||
},
|
||||
actorForSecret(actor),
|
||||
);
|
||||
const written = await db.transaction((tx) => writeConnectionCredential(tx, {
|
||||
companyId, connectionName: name, configPath: REMOTE_URL_SECRET_CONFIG_PATH,
|
||||
label: "MCP server URL", value: remoteUrlCredential.secretUrl!,
|
||||
ownerUserId: personalIdentityUserId, actor: actorForSecret(actor),
|
||||
}));
|
||||
const { secret } = written;
|
||||
if (written.definitionId) createdDefinitionIds.push(written.definitionId);
|
||||
createdSecretIds.push(secret.id);
|
||||
credentialSecretRefs.push({
|
||||
secretId: secret.id,
|
||||
@@ -12873,11 +12548,17 @@ export function toolAccessService(
|
||||
let changedGrant: typeof connectionGrants.$inferSelect;
|
||||
let previousGrant: typeof connectionGrants.$inferSelect | null = null;
|
||||
if (retainedPersonalIdentity?.grant) {
|
||||
const [currentGrant] = await db
|
||||
.select()
|
||||
const [grantSnapshot] = await db
|
||||
.select({
|
||||
grant: connectionGrants,
|
||||
// Preserve PostgreSQL microseconds for the optimistic update;
|
||||
// a JavaScript Date truncates them and falsely reports a race.
|
||||
updatedAtVersion: sql<string>`${connectionGrants.updatedAt}::text`,
|
||||
})
|
||||
.from(connectionGrants)
|
||||
.where(eq(connectionGrants.id, retainedPersonalIdentity.grant.id))
|
||||
.limit(1);
|
||||
const currentGrant = grantSnapshot?.grant;
|
||||
if (!currentGrant)
|
||||
throw conflict(
|
||||
"The personal credential changed during setup. Please try again.",
|
||||
@@ -12896,7 +12577,7 @@ export function toolAccessService(
|
||||
.where(
|
||||
and(
|
||||
eq(connectionGrants.id, currentGrant.id),
|
||||
eq(connectionGrants.updatedAt, currentGrant.updatedAt),
|
||||
sql`${connectionGrants.updatedAt} = ${grantSnapshot!.updatedAtVersion}::timestamptz`,
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
@@ -14011,12 +13692,23 @@ export function toolAccessService(
|
||||
const galleryEntry = sourceTemplateKey
|
||||
? getConnectableAppDefinition(sourceTemplateKey)
|
||||
: null;
|
||||
const credentialFields = galleryEntry
|
||||
? credentialFieldsFor(
|
||||
galleryEntry,
|
||||
connectionMethodForConnection(galleryEntry, connection).key,
|
||||
)
|
||||
: [
|
||||
const storedCredentialFields = connection.credentialRefs
|
||||
.filter((ref) => ref.placement === "header" || ref.placement === "url")
|
||||
.map((ref) => ({
|
||||
label: ref.placement === "url" ? "MCP server URL" : ref.prefix === "Bearer " ? "App key" : ref.key ?? ref.name,
|
||||
configPath: credentialRefConfigPath(ref),
|
||||
helpUrl: "",
|
||||
required: false,
|
||||
placement: ref.placement,
|
||||
key: ref.key,
|
||||
prefix: ref.prefix,
|
||||
}));
|
||||
const galleryCredentialFields = galleryEntry
|
||||
? credentialFieldsFor(galleryEntry, connectionMethodForConnection(galleryEntry, connection).key)
|
||||
: [];
|
||||
const credentialFields = galleryCredentialFields.length > 0
|
||||
? galleryCredentialFields
|
||||
: storedCredentialFields.length > 0 ? storedCredentialFields : [
|
||||
{
|
||||
label: "App key",
|
||||
configPath: "credentials.authorization",
|
||||
@@ -14033,7 +13725,7 @@ export function toolAccessService(
|
||||
(input.credentialValues[field.configPath]?.trim().length ?? 0) > 0,
|
||||
);
|
||||
if (providedFields.length === 0)
|
||||
throw badRequest("Paste a new key to reconnect this app");
|
||||
throw badRequest("Enter a replacement credential to reconnect this app");
|
||||
|
||||
const personalIdentity = await fixedPersonalIdentityForReconnect(
|
||||
connection,
|
||||
@@ -14048,59 +13740,45 @@ export function toolAccessService(
|
||||
...(connection.credentialRefs ?? []),
|
||||
];
|
||||
|
||||
for (const field of providedFields) {
|
||||
const value = input.credentialValues[field.configPath]!.trim();
|
||||
const existing = credentialSecretRefs.find(
|
||||
(ref) => ref.configPath === field.configPath,
|
||||
);
|
||||
if (existing) {
|
||||
await secrets.rotate(
|
||||
existing.secretId,
|
||||
{ value },
|
||||
actorForSecret(actor),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const metadata = {
|
||||
name: `${connection.name} ${field.label} ${randomUUID().slice(0, 8)}`,
|
||||
key: `tool_app.${randomUUID()}.${field.configPath.replace(/[^a-z0-9_:-]+/gi, "_")}`,
|
||||
provider: "local_encrypted" as const,
|
||||
description: `Credential for ${connection.name} (${field.configPath}).`,
|
||||
};
|
||||
const secret = personalIdentity
|
||||
? await db.transaction(async (tx) => {
|
||||
const vault = secretService(tx as unknown as Db);
|
||||
const definition = await vault.createUserSecretDefinition(companyId, metadata, actorForSecret(actor));
|
||||
return vault.createCurrentUserSecretValue(companyId, personalIdentity.subjectUserId,
|
||||
{ definitionId: definition.id, value }, actorForSecret(actor));
|
||||
})
|
||||
: await secrets.create(companyId, { ...metadata, value }, actorForSecret(actor));
|
||||
credentialSecretRefs.push({
|
||||
secretId: secret.id,
|
||||
versionSelector: "latest",
|
||||
configPath: field.configPath,
|
||||
required: field.required ?? true,
|
||||
label: field.label,
|
||||
});
|
||||
if (field.placement === "header" && field.key) {
|
||||
const nextCredentialRef = {
|
||||
name: field.configPath,
|
||||
secretId: secret.id,
|
||||
version: "latest",
|
||||
placement: "header",
|
||||
key: field.key,
|
||||
prefix: field.prefix ?? null,
|
||||
} satisfies McpConnectionCredentialRef;
|
||||
const existingCredentialRefIndex = credentialRefs.findIndex(
|
||||
(ref) => ref.name === field.configPath,
|
||||
);
|
||||
if (existingCredentialRefIndex >= 0)
|
||||
credentialRefs[existingCredentialRefIndex] = nextCredentialRef;
|
||||
else credentialRefs.push(nextCredentialRef);
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await db.transaction(async (tx) => {
|
||||
for (const field of providedFields) {
|
||||
const value = input.credentialValues[field.configPath]!.trim();
|
||||
if (field.placement === "url" && !remoteUrlCredentialMatchesPublicUrl(String(connection.config.url ?? ""), value)) {
|
||||
throw badRequest("The replacement server URL must use the same endpoint as this connection.", {
|
||||
code: "mcp_remote_url_credential_mismatch",
|
||||
});
|
||||
}
|
||||
const existing = credentialSecretRefs.find(
|
||||
(ref) => ref.configPath === field.configPath,
|
||||
);
|
||||
const { secret } = await writeConnectionCredential(tx, {
|
||||
companyId, connectionName: connection.name, configPath: field.configPath,
|
||||
label: field.label, value, ownerUserId: personalIdentity?.subjectUserId,
|
||||
existingRef: existing, actor: actorForSecret(actor),
|
||||
});
|
||||
const nextRef = { secretId: secret.id, versionSelector: "latest" as const,
|
||||
configPath: field.configPath, required: field.required ?? true, label: field.label };
|
||||
const refIndex = credentialSecretRefs.findIndex((ref) => ref.configPath === field.configPath);
|
||||
if (refIndex >= 0) credentialSecretRefs[refIndex] = nextRef;
|
||||
else credentialSecretRefs.push(nextRef);
|
||||
if ((field.placement === "header" && field.key) || field.placement === "url") {
|
||||
const nextCredentialRef = {
|
||||
name: field.configPath,
|
||||
secretId: secret.id,
|
||||
version: "latest",
|
||||
placement: field.placement,
|
||||
key: field.key ?? "url",
|
||||
prefix: field.prefix ?? null,
|
||||
} satisfies McpConnectionCredentialRef;
|
||||
const existingCredentialRefIndex = credentialRefs.findIndex(
|
||||
(ref) => credentialRefConfigPath(ref) === field.configPath,
|
||||
);
|
||||
if (existingCredentialRefIndex >= 0)
|
||||
credentialRefs[existingCredentialRefIndex] = nextCredentialRef;
|
||||
else credentialRefs.push(nextCredentialRef);
|
||||
}
|
||||
}
|
||||
|
||||
const updatedAt = new Date();
|
||||
if (personalIdentity) {
|
||||
const grantValues = {
|
||||
@@ -14142,12 +13820,10 @@ export function toolAccessService(
|
||||
})
|
||||
.where(eq(toolConnections.id, connection.id))
|
||||
.returning();
|
||||
await syncConnectionCredentialBindings(tx, nextConnection,
|
||||
personalIdentity ? credentialSecretRefs : []);
|
||||
return nextConnection;
|
||||
});
|
||||
await syncCredentialBindings(
|
||||
updated,
|
||||
personalIdentity ? credentialSecretRefs : [],
|
||||
);
|
||||
const health = await checkConnectionHealth(updated.id, actor);
|
||||
const refresh = await refreshCatalog(updated.id, actor, {
|
||||
enableAllByDefault: true,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isInsufficientConnectionScope, INSUFFICIENT_CONNECTION_SCOPE_MESSAGE } from "./connection-permission-errors.js";
|
||||
import { browserUseService } from "./browser-use.js";
|
||||
import { isBrowserUseConnection } from "./browser-use-client.js";
|
||||
import { COGNEE_STDIO_TEMPLATE, cogneeCloudUrl, callCogneeCloud } from "./cognee-connection.js";
|
||||
@@ -99,6 +100,7 @@ import type {
|
||||
} from "./plugin-tool-dispatcher.js";
|
||||
import { logActivity, type LogActivityInput } from "./activity-log.js";
|
||||
import { secretService } from "./secrets.js";
|
||||
import { connectionGrantCredentialRef, resolveConnectionGrantSecret } from "./connection-credentials.js";
|
||||
import { railwayCommandBudgetMs, createRailwayClient, isRailwayConnection, isRailwayEndpoint, isRailwayToolBlocked, normalizeRailwayToolName, RAILWAY_API_URL, RAILWAY_TOOL_PREFIX, RailwayError } from "./railway.js";
|
||||
import { RAILWAY_SSH_SECRET_PATH, runRailwaySshCommand } from "./railway-ssh.js";
|
||||
import {
|
||||
@@ -3187,7 +3189,6 @@ export function createToolGatewayService(
|
||||
connection,
|
||||
grant,
|
||||
grantRef,
|
||||
REMOTE_URL_SECRET_CONFIG_PATH,
|
||||
);
|
||||
if (!remoteUrlCredentialMatchesPublicUrl(publicEndpoint, value)) {
|
||||
throw new ToolGatewayHttpError(
|
||||
@@ -3529,11 +3530,7 @@ export function createToolGatewayService(
|
||||
grant: typeof connectionGrants.$inferSelect,
|
||||
ref: McpConnectionCredentialRef,
|
||||
): ToolCredentialSecretRef | undefined {
|
||||
return grant.credentialSecretRefs.find(
|
||||
(candidate) =>
|
||||
candidate.configPath === ref.name ||
|
||||
candidate.configPath === `credentials.${ref.name}`,
|
||||
);
|
||||
return connectionGrantCredentialRef(grant, ref);
|
||||
}
|
||||
|
||||
async function resolveGrantSecretValue(
|
||||
@@ -3541,91 +3538,18 @@ export function createToolGatewayService(
|
||||
connection: typeof toolConnections.$inferSelect,
|
||||
grant: typeof connectionGrants.$inferSelect,
|
||||
ref: ToolCredentialSecretRef,
|
||||
configPath = ref.configPath,
|
||||
): Promise<string> {
|
||||
const accessContext = {
|
||||
consumerType: "tool_connection" as const,
|
||||
consumerId: connection.id,
|
||||
configPath,
|
||||
actorType: "system" as const,
|
||||
actorId: session.agentId,
|
||||
responsibleUserId: grant.subjectUserId,
|
||||
issueId: session.issueId,
|
||||
heartbeatRunId: session.runId,
|
||||
};
|
||||
if (grant.kind !== "user") {
|
||||
return secrets.resolveSecretValue(
|
||||
connection.companyId,
|
||||
ref.secretId,
|
||||
ref.versionSelector ?? "latest",
|
||||
{ accessContext },
|
||||
);
|
||||
try {
|
||||
return (await resolveConnectionGrantSecret(db, connection, grant, ref, {
|
||||
consumerType: "tool_connection", consumerId: connection.id,
|
||||
actorType: "system", actorId: session.agentId,
|
||||
responsibleUserId: grant.subjectUserId, issueId: session.issueId, heartbeatRunId: session.runId,
|
||||
})).value;
|
||||
} catch (error) {
|
||||
if (error instanceof HttpError) throw new ToolGatewayHttpError(error.status, error.message,
|
||||
String(asRecord(error.details)?.code ?? "mcp_remote_missing_secret"), asRecord(error.details) ?? {});
|
||||
throw error;
|
||||
}
|
||||
if (!grant.subjectUserId) {
|
||||
throw new ToolGatewayHttpError(
|
||||
422,
|
||||
"Personal authorization has no owner",
|
||||
"grant_owner_missing",
|
||||
{
|
||||
connectionId: connection.id,
|
||||
grantId: grant.id,
|
||||
},
|
||||
);
|
||||
}
|
||||
const [secret] = await db
|
||||
.select({
|
||||
scope: companySecrets.scope,
|
||||
ownerUserId: companySecrets.ownerUserId,
|
||||
userSecretDefinitionId: companySecrets.userSecretDefinitionId,
|
||||
})
|
||||
.from(companySecrets)
|
||||
.where(
|
||||
and(
|
||||
eq(companySecrets.id, ref.secretId),
|
||||
eq(companySecrets.companyId, connection.companyId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (
|
||||
!secret ||
|
||||
secret.scope !== "user" ||
|
||||
secret.ownerUserId !== grant.subjectUserId ||
|
||||
!secret.userSecretDefinitionId
|
||||
) {
|
||||
throw new ToolGatewayHttpError(
|
||||
422,
|
||||
"Personal authorization has an invalid credential",
|
||||
"grant_credential_invalid",
|
||||
{
|
||||
connectionId: connection.id,
|
||||
grantId: grant.id,
|
||||
credential: configPath,
|
||||
},
|
||||
);
|
||||
}
|
||||
const resolved = await secrets.resolveUserSecretValue(
|
||||
connection.companyId,
|
||||
{
|
||||
definitionId: secret.userSecretDefinitionId,
|
||||
responsibleUserId: grant.subjectUserId,
|
||||
version: ref.versionSelector ?? "latest",
|
||||
required: ref.required ?? true,
|
||||
},
|
||||
accessContext,
|
||||
);
|
||||
if (!resolved) {
|
||||
throw new ToolGatewayHttpError(
|
||||
422,
|
||||
"Personal credential is not configured",
|
||||
"user_secret_missing",
|
||||
{
|
||||
connectionId: connection.id,
|
||||
grantId: grant.id,
|
||||
credential: configPath,
|
||||
},
|
||||
);
|
||||
}
|
||||
return resolved.value;
|
||||
}
|
||||
|
||||
async function maybeRefreshPaperclipCloudGrant(
|
||||
@@ -4105,20 +4029,15 @@ export function createToolGatewayService(
|
||||
connection,
|
||||
grant,
|
||||
grantRef,
|
||||
// Personal grants resolve against their declared vault path. API-key
|
||||
// paths already include credentials.* or headers.*; prepending again
|
||||
// breaks the declaration just as it does for OAuth token paths.
|
||||
grant.kind === "user" || grantRef.configPath.startsWith("oauth.")
|
||||
? grantRef.configPath
|
||||
: `credentials.${ref.name}`,
|
||||
);
|
||||
headers[ref.key] = `${ref.prefix ?? ""}${value}`;
|
||||
} catch {
|
||||
} catch (error) {
|
||||
await markRemoteConnectionHealth(
|
||||
connection,
|
||||
"missing_secret",
|
||||
"A configured credential secret could not be resolved.",
|
||||
);
|
||||
if (error instanceof ToolGatewayHttpError && error.reasonCode === "grant_credential_invalid") throw error;
|
||||
throw new ToolGatewayHttpError(
|
||||
422,
|
||||
"A configured credential secret could not be resolved.",
|
||||
@@ -4139,12 +4058,13 @@ export function createToolGatewayService(
|
||||
oauthAccessRef,
|
||||
);
|
||||
headers.Authorization = `Bearer ${value}`;
|
||||
} catch {
|
||||
} catch (error) {
|
||||
await markRemoteConnectionHealth(
|
||||
connection,
|
||||
"missing_secret",
|
||||
"A configured credential secret could not be resolved.",
|
||||
);
|
||||
if (error instanceof ToolGatewayHttpError && error.reasonCode === "grant_credential_invalid") throw error;
|
||||
throw new ToolGatewayHttpError(
|
||||
422,
|
||||
"A configured credential secret could not be resolved.",
|
||||
@@ -4226,7 +4146,7 @@ export function createToolGatewayService(
|
||||
const configPath =
|
||||
typedRef.placement === "url"
|
||||
? REMOTE_URL_SECRET_CONFIG_PATH
|
||||
: `credentials.${typedRef.name}`;
|
||||
: grantRef.configPath;
|
||||
headerCredentialVersions.push(
|
||||
await resolveConnectedCredentialVersion(connection, {
|
||||
secretId: grantRef.secretId,
|
||||
@@ -5987,6 +5907,11 @@ export function createToolGatewayService(
|
||||
}),
|
||||
};
|
||||
let response = await dispatchRemote(endpoint, requestInit);
|
||||
if (isInsufficientConnectionScope(response)) {
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
await markRemoteConnectionHealth(connection, "degraded", INSUFFICIENT_CONNECTION_SCOPE_MESSAGE);
|
||||
throw new ToolGatewayHttpError(403, INSUFFICIENT_CONNECTION_SCOPE_MESSAGE, "oauth_insufficient_scope", { connectionId: connection.id });
|
||||
}
|
||||
const oauth = asRecord(asRecord(connection.config)?.oauth);
|
||||
if (
|
||||
response.status === 401 &&
|
||||
@@ -6128,6 +6053,12 @@ export function createToolGatewayService(
|
||||
response.headers.get("x-zapier-request-id") ??
|
||||
response.headers.get("traceparent"),
|
||||
};
|
||||
if (isInsufficientConnectionScope(response, body)) {
|
||||
await markRemoteConnectionHealth(connection, "degraded", INSUFFICIENT_CONNECTION_SCOPE_MESSAGE);
|
||||
throw new ToolGatewayHttpError(403, INSUFFICIENT_CONNECTION_SCOPE_MESSAGE, "oauth_insufficient_scope", {
|
||||
connectionId: connection.id, catalogEntryId: entry.id,
|
||||
});
|
||||
}
|
||||
if (!response.ok) {
|
||||
// Session expiration is recoverable on an explicit retry. Marking the
|
||||
// connection unhealthy here would hide every tool and prevent it.
|
||||
|
||||
@@ -183,9 +183,10 @@ test.describe.serial("prosumer MCP flow prosumer MCP flow", () => {
|
||||
// Verify the mock saw a tools/list call from the catalog refresh.
|
||||
expect(mock.captures.some((c) => c.method === "tools/list")).toBe(true);
|
||||
|
||||
// The new connection should show up on /apps/connections.
|
||||
await gotoApps(page, seed.prefix);
|
||||
// Return through the UI so a page reload cannot hide a stale catalog cache.
|
||||
await page.getByRole("button", { name: "View connection" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByRole("heading", { name: `${new URL(mock.url).host} for the organization`, exact: true })).toBeVisible();
|
||||
await page.screenshot({ path: `${SCREENSHOT_DIR}/prosumer-mcp-06-apps-list.png`, fullPage: true });
|
||||
});
|
||||
|
||||
@@ -243,10 +244,12 @@ test.describe.serial("prosumer MCP flow prosumer MCP flow", () => {
|
||||
});
|
||||
expect(repatch.ok(), `patch url failed ${repatch.status()}: ${await repatch.text()}`).toBe(true);
|
||||
|
||||
const reconnect = await request.post(`/api/tool-connections/${connectionId}/reconnect`, {
|
||||
data: { credentialValues: { "credentials.authorization": "fresh-key" } },
|
||||
});
|
||||
expect(reconnect.ok(), `reconnect failed ${reconnect.status()}: ${await reconnect.text()}`).toBe(true);
|
||||
await page.reload();
|
||||
await page.getByLabel("App key", { exact: true }).fill("fresh-key");
|
||||
await page.getByRole("button", { name: "Check & reconnect", exact: true }).click();
|
||||
await expect(page.getByText("Reconnected", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "This app needs reconnecting" })).toHaveCount(0);
|
||||
await expect(page.getByText("Still not working", { exact: true })).toHaveCount(0);
|
||||
|
||||
const after = await request.get(`/api/tool-connections/${connectionId}`);
|
||||
const afterBody = await after.json();
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useMemoryConnectorsEnabled } from "@/hooks/useMemoryConnectorsEnabled";
|
||||
import { AiConnectionCredentialStep } from "@/components/ai-connections/AiConnectionCredentialStep";
|
||||
import { ConnectionChoiceList } from "./ConnectionChoiceList";
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode, type Ref } from "react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowUpRight,
|
||||
Bot,
|
||||
@@ -583,6 +583,7 @@ function StandardConnectionSetupFlow({
|
||||
onCancel,
|
||||
renderCredentialStep,
|
||||
}: ConnectionSetupFlowProps = {}) {
|
||||
const queryClient = useQueryClient();
|
||||
const routeNavigate = useNavigate();
|
||||
const navigate = useCallback((to: string, options?: { replace?: boolean }) => {
|
||||
if (host !== "page") return;
|
||||
@@ -1717,7 +1718,9 @@ function StandardConnectionSetupFlow({
|
||||
await applyAccessInstalls(connected.connectionId);
|
||||
return finished;
|
||||
},
|
||||
onSuccess: (_finished, input) => {
|
||||
onSuccess: async (_finished, input) => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["tools"] });
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId!) });
|
||||
setAppStep("success");
|
||||
onComplete?.({ connectionId: input.result.connectionId });
|
||||
},
|
||||
@@ -3143,6 +3146,9 @@ function LinkConnectStep({
|
||||
placeholder="••••••••••••••••"
|
||||
className="mt-2 h-11 font-mono"
|
||||
/>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Grant this key read and write access to the resources your agents need. Paperclip cannot increase its permissions.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -3470,7 +3476,8 @@ function KeyStep({
|
||||
const optionalCustomerOAuthClient = !usingVercel
|
||||
&& acceptsCustomerOAuthClient
|
||||
&& !customerOAuthClientRequired;
|
||||
const hasAdvancedSettings = advancedConfigFields.length > 0 || optionalCustomerOAuthClient;
|
||||
const hasReadOnlyAlternatives = capabilityGroups.length > 1 && capabilityGroups.some((group) => group.key === "read");
|
||||
const hasAdvancedSettings = advancedConfigFields.length > 0 || optionalCustomerOAuthClient || hasReadOnlyAlternatives;
|
||||
const capabilitySelection = capabilityGroups.length > 1 ? (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-foreground">What should Paperclip be able to do?</label>
|
||||
@@ -3614,7 +3621,12 @@ function KeyStep({
|
||||
) : null}
|
||||
|
||||
<div className="space-y-6">
|
||||
{capabilitySelection}
|
||||
{!hasReadOnlyAlternatives && capabilitySelection}
|
||||
{(hasReadOnlyAlternatives || capabilityGroups.length === 1) && method?.capabilityProfile && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{method.capabilityProfile.label}: {method.capabilityProfile.description}
|
||||
</p>
|
||||
)}
|
||||
{authenticationSelection}
|
||||
|
||||
{usingVercel && vercelReview && vercelConnectAvailability ? (
|
||||
@@ -3672,6 +3684,7 @@ function KeyStep({
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pt-4">
|
||||
<div className="space-y-6">
|
||||
{hasReadOnlyAlternatives && capabilitySelection}
|
||||
{advancedConfigFields.map((field) => (
|
||||
<MethodConfigField
|
||||
key={field.key}
|
||||
@@ -3727,6 +3740,9 @@ function KeyStep({
|
||||
placeholder={field.type === "text" && field.secret === false ? field.placeholder : "••••••••••••••••"}
|
||||
className="mt-2 h-11 font-mono"
|
||||
/>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{field.helperMd ?? "Create a key with read and write permissions for the resources your agents need. Paperclip cannot add permissions to an existing key."}
|
||||
</p>
|
||||
{field.helpUrl && (
|
||||
<a
|
||||
href={field.helpUrl}
|
||||
|
||||
@@ -25,7 +25,8 @@ function FieldHelp({ label, children }: { label: string; children: ReactNode })
|
||||
|
||||
/** Controlled presentation shared by provider setup, configuration imports and review stories.
|
||||
* Authentication, persistence and calls belong to the controller, never these views. */
|
||||
export function RemoteMcpConnectionSetup({ provider, state: s, actions: a, agents, connectionId, fixedGrantKind, lockedAgentId, host = "page", authorizationUrl, upstreamServiceName }: {
|
||||
export function RemoteMcpConnectionSetup({ provider, state: s, actions: a, agents, connectionId, fixedGrantKind, lockedAgentId, host = "page", authorizationUrl, upstreamServiceName, onCancel }: {
|
||||
onCancel?: () => void;
|
||||
upstreamServiceName?: string;
|
||||
host?: "page" | "dialog";
|
||||
lockedAgentId?: string;
|
||||
@@ -63,7 +64,7 @@ export function RemoteMcpConnectionSetup({ provider, state: s, actions: a, agent
|
||||
<StepHeader headingRef={heading} appIdentity={{ name: provider.name, logoUrl: null }}
|
||||
title={upstreamServiceName ? `Connect ${upstreamServiceName} through ${provider.name}` : s.step === "draft" ? "Continue your setup" : s.setupComplete ? s.step === "access" ? "Who can use this connection" : s.step === "connect" ? `Reconnect ${provider.name}` : provider.name : undefined}
|
||||
subtitle={currentStep >= 0 && !s.setupComplete ? `Step ${currentStep + 1} of 2` : s.step === "draft" ? `Your ${provider.name} setup is ready to resume.` : s.step === "permissions" ? `Connected${s.identity ? ` as ${s.identity}` : ""} · ${s.tools.length} actions available` : `Manage this ${provider.name} connection.`}
|
||||
step={currentStep >= 0 && !s.setupComplete ? "access" : "gallery"} activeIndex={currentStep} labels={["Access", "Connect"]} onCancel={busy || s.step === "management" || s.step === "permissions" || s.step === "draft" ? undefined : a.saveExit} />
|
||||
step={currentStep >= 0 && !s.setupComplete ? "access" : "gallery"} activeIndex={currentStep} labels={["Access", "Connect"]} onCancel={busy || s.step === "management" || s.step === "permissions" || s.step === "draft" ? undefined : onCancel ?? a.saveExit} />
|
||||
<main className="space-y-6">
|
||||
{upstreamServiceName && <InlineBanner compact>{provider.name} is an external service that handles the connection and requests to {upstreamServiceName}. After connecting, the agent will verify the app and guide you through any additional authorization.</InlineBanner>}
|
||||
{s.notice && <p role="status" className="text-sm text-muted-foreground">{s.notice}</p>}
|
||||
|
||||
@@ -187,5 +187,7 @@ export function RemoteMcpProductionSetup({ providerId, connection, host = "page"
|
||||
void onUseExisting(id).catch((error) => { setChoiceError(error instanceof Error ? error.message : "Could not use this connection."); setChoicePending(null); });
|
||||
}} />;
|
||||
if (connection && !installs.data) return <div className="space-y-3 p-8"><p>{installs.isError ? "Could not load saved access. Retry before changing this connection." : "Loading saved access…"}</p>{installs.isError && <button type="button" className="text-primary underline" onClick={() => void installs.refetch()}>Try again</button>}</div>;
|
||||
return <RemoteMcpConnectionSetup upstreamServiceName={upstreamServiceName} host={host} lockedAgentId={requestedAgentId} authorizationUrl={host === "dialog" ? authorizationUrl.current : undefined} provider={provider} connectionId={savedConnection.current?.id ?? ""} fixedGrantKind={savedConnection.current ? savedConnection.current.credentialPolicy === "per_user" ? "user" : "organization" : undefined} state={state} actions={actions} agents={agents.data ?? []} />;
|
||||
// Header Cancel abandons unsaved input, including invalid URLs. The separate
|
||||
// Save & exit action persists a resumable draft through actions.saveExit.
|
||||
return <RemoteMcpConnectionSetup onCancel={onCancel ?? (() => navigate("/apps"))} upstreamServiceName={upstreamServiceName} host={host} lockedAgentId={requestedAgentId} authorizationUrl={host === "dialog" ? authorizationUrl.current : undefined} provider={provider} connectionId={savedConnection.current?.id ?? ""} fixedGrantKind={savedConnection.current ? savedConnection.current.credentialPolicy === "per_user" ? "user" : "organization" : undefined} state={state} actions={actions} agents={agents.data ?? []} />;
|
||||
}
|
||||
@@ -29,6 +29,7 @@ const putConnectionInstallsMock = vi.hoisted(() => vi.fn());
|
||||
const refreshCatalogMock = vi.hoisted(() => vi.fn());
|
||||
const checkConnectionHealthMock = vi.hoisted(() => vi.fn());
|
||||
const startOAuthMock = vi.hoisted(() => vi.fn());
|
||||
const reconnectConnectionMock = vi.hoisted(() => vi.fn());
|
||||
const listConnectionGrantsMock = vi.hoisted(() => vi.fn());
|
||||
const revokeConnectionGrantMock = vi.hoisted(() => vi.fn());
|
||||
const createConnectionGrantDelegationMock = vi.hoisted(() => vi.fn());
|
||||
@@ -88,7 +89,7 @@ vi.mock("@/api/tools", () => ({
|
||||
replaceConnectionGrantMembersMock(connectionId, grantId, memberUserIds),
|
||||
startPersonalAuthorization: (companyId: string, connectionId: string, input: unknown) =>
|
||||
startPersonalAuthorizationMock(companyId, connectionId, input),
|
||||
reconnectConnection: vi.fn(),
|
||||
reconnectConnection: (id: string, values: unknown) => reconnectConnectionMock(id, values),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1207,6 +1208,31 @@ describe("AppDetail", () => {
|
||||
expect(container.textContent).toContain("Which agents can use this connection?");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "remote.url", placement: "url", key: "url", prefix: null, label: "MCP server URL", value: "https://example.com/mcp?token=fresh", path: "remote.url" },
|
||||
{ name: "headers.X-Api-Key", placement: "header", key: "X-Api-Key", prefix: null, label: "X-Api-Key", value: "fresh-key", path: "headers.X-Api-Key" },
|
||||
{ name: "authorization", placement: "header", key: "Authorization", prefix: "Bearer ", label: "App key", value: "fresh-token", path: "credentials.authorization" },
|
||||
])("reconnects a generic $label using its stored credential placement", async (fixture) => {
|
||||
listGalleryMock.mockResolvedValue({ apps: [] });
|
||||
listApplicationsMock.mockResolvedValue({ applications: [] });
|
||||
getConnectionMock.mockResolvedValue(connection({
|
||||
authKind: "api_key",
|
||||
healthStatus: "missing_secret",
|
||||
credentialRefs: [{ ...fixture, secretId: "old-secret", version: "latest" }],
|
||||
}));
|
||||
reconnectConnectionMock.mockResolvedValue({ connection: connection({ healthStatus: "ok" }) });
|
||||
await renderAppDetail();
|
||||
|
||||
const field = container.querySelector<HTMLInputElement>(`input[aria-label="${fixture.label}"]`);
|
||||
expect(field).not.toBeNull();
|
||||
await act(() => setInputValue(field!, fixture.value));
|
||||
await act(() => findButton("Check & reconnect")!.click());
|
||||
await flushReact();
|
||||
|
||||
expect(reconnectConnectionMock).toHaveBeenCalledWith("conn-1", { [fixture.path]: fixture.value });
|
||||
expect(pushToastMock).toHaveBeenCalledWith(expect.objectContaining({ title: "Reconnected" }));
|
||||
});
|
||||
|
||||
it.each(["permissions", "review"])("offers a supported replacement for an obsolete Anthropic connection on %s", async (tab) => {
|
||||
mockParams.tab = tab;
|
||||
listApplicationsMock.mockResolvedValue({ applications: [] });
|
||||
|
||||
@@ -657,6 +657,18 @@ export function AppDetail({ renderActions, onReconnect }: {
|
||||
replaceAudience.mutate({ grantId: grant.id, memberUserIds })}
|
||||
/>
|
||||
{isRemoteMcpConnectorMethod(connection.config?.sourceTemplateKey, connection.config?.connectionMethodKey) && <p className="text-sm text-muted-foreground">Paperclip controls access to the tools listed here. App and action permissions inside these tools are managed in {baseAppName}.</p>}
|
||||
{connection.authKind === "oauth" && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Provider permissions come from your last sign-in. Reconnect to grant missing write access, then enable the actions you need here.
|
||||
</p>
|
||||
{canReconnect && <Button variant="outline" onClick={() => onReconnect
|
||||
? onReconnect(connection)
|
||||
: navigate(`/apps/connect?source=${connection.config?.sourceTemplateKey}&reconnect=${connection.id}`)}>
|
||||
Reconnect to update permissions
|
||||
</Button>}
|
||||
</div>
|
||||
)}
|
||||
<PermissionsPanel
|
||||
actions={actionsContent}
|
||||
connectionId={connectionId}
|
||||
|
||||
@@ -416,6 +416,22 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
expect(container.textContent).toContain("Step 2 of 2");
|
||||
});
|
||||
|
||||
it.each(["page", "dialog"] as const)("lets %s setup cancel after an invalid provider URL without trying to save it", async (host) => {
|
||||
const onCancel = host === "dialog" ? vi.fn() : undefined;
|
||||
connectAppMock.mockRejectedValue(new Error("That connection URL does not belong to Zapier"));
|
||||
await render(undefined, false, <ConnectionSetupFlow host={host} serviceSlug="zapier" onCancel={onCancel} />);
|
||||
await passAccessStep();
|
||||
await act(async () => setInputValue(container.querySelector<HTMLInputElement>('input[type="password"]')!, "https://wrong-provider.example/mcp"));
|
||||
await act(async () => buttonByText("Connect")!.click());
|
||||
await vi.waitFor(() => expect(container.textContent).toContain("That connection URL does not belong to Zapier"));
|
||||
|
||||
await act(async () => buttonByText("Cancel")!.click());
|
||||
|
||||
expect(connectAppMock).toHaveBeenCalledTimes(1);
|
||||
if (onCancel) expect(onCancel).toHaveBeenCalledOnce();
|
||||
else expect(mockNavigate).toHaveBeenCalledWith("/apps");
|
||||
});
|
||||
|
||||
it("inline aggregator reuses an eligible account without changing its access", async () => {
|
||||
const onUseExisting = vi.fn().mockResolvedValue(undefined);
|
||||
await render(undefined, false, <ConnectionSetupFlow host="dialog" serviceSlug="composio" requestedAgentId="agent-1" interactionId="intent-inline" existingConnections={[{ id: "existing", applicationId: "app", name: "Existing Composio", status: "active", enabled: true }]} onUseExisting={onUseExisting} />);
|
||||
@@ -1103,7 +1119,8 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
if (!popupBlocked) expect(popup.close).toHaveBeenCalled();
|
||||
expect(container.textContent).toContain("What should Paperclip be able to do?");
|
||||
expect(container.textContent).not.toContain("What should Paperclip be able to do?");
|
||||
expect(buttonByText("Advanced")).toBeDefined();
|
||||
expect(container.textContent).toContain("Step 2 of 2");
|
||||
connectAppMock.mockResolvedValue({ connectionId: "gmail-1", connection: { id: "gmail-1", credentialPolicy: "per_user" }, auth: { kind: "oauth", startUrl: "https://example.test/unbound" } });
|
||||
await act(async () => buttonByText("Continue to sign in")?.click());
|
||||
@@ -1465,6 +1482,9 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
}
|
||||
|
||||
if (definition.methods.some((method) => method.capabilityProfile?.key !== "read")) {
|
||||
expect(radioContaining(readMethod.capabilityProfile!.label)).toBeUndefined();
|
||||
await act(async () => { buttonByText("Advanced")!.dispatchEvent(new MouseEvent("click", { bubbles: true })); });
|
||||
await flushReact();
|
||||
const readChoice = radioContaining(readMethod.capabilityProfile!.label);
|
||||
expect(readChoice).not.toBeNull();
|
||||
await act(async () => {
|
||||
@@ -1537,6 +1557,10 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
expect(radioContaining("Any human in the organization")?.getAttribute("aria-checked")).toBe("true");
|
||||
await passAccessStep();
|
||||
|
||||
expect(container.textContent).toContain("Read & create");
|
||||
expect(radioContaining("Read only")).toBeUndefined();
|
||||
await act(async () => { buttonByText("Advanced")!.dispatchEvent(new MouseEvent("click", { bubbles: true })); });
|
||||
await flushReact();
|
||||
expect(radioContaining("Read & create")?.getAttribute("aria-checked")).toBe("true");
|
||||
expect(radioContaining("Read only")?.getAttribute("aria-checked")).toBe("false");
|
||||
expect(container.textContent).not.toContain("Before connecting, enroll the signed-in Workspace account");
|
||||
@@ -1555,7 +1579,8 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
(heading) => heading.textContent?.trim() === "Connect Google Calendar",
|
||||
);
|
||||
expect(duplicateHeadings).toHaveLength(1);
|
||||
expect(container.textContent).toContain("What should Paperclip be able to do?");
|
||||
expect(container.textContent).not.toContain("What should Paperclip be able to do?");
|
||||
expect(buttonByText("Advanced")).toBeDefined();
|
||||
expect(container.textContent).toContain("Review requirements");
|
||||
expect(container.textContent).not.toContain("Connect Google Calendar to read and manage events.");
|
||||
expect(container.textContent).not.toContain("All event mutations require approval.");
|
||||
@@ -1565,6 +1590,8 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
expect(container.textContent).not.toContain("You’ll sign in before anything turns on.");
|
||||
expect(container.querySelector('input[placeholder="My app"]')).toBeNull();
|
||||
|
||||
await act(async () => { buttonByText("Advanced")!.dispatchEvent(new MouseEvent("click", { bubbles: true })); });
|
||||
await flushReact();
|
||||
const capabilityQuestion = Array.from(container.querySelectorAll("label")).find(
|
||||
(label) => label.textContent === "What should Paperclip be able to do?",
|
||||
);
|
||||
@@ -2986,6 +3013,8 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
});
|
||||
await flushReact();
|
||||
await passAccessStep();
|
||||
await act(async () => { buttonByText("Advanced")!.dispatchEvent(new MouseEvent("click", { bubbles: true })); });
|
||||
await flushReact();
|
||||
await act(async () => {
|
||||
buttonContaining("Share selected sheets")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
@@ -3012,6 +3041,8 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
});
|
||||
await flushReact();
|
||||
await passAccessStep();
|
||||
await act(async () => { buttonByText("Advanced")!.dispatchEvent(new MouseEvent("click", { bubbles: true })); });
|
||||
await flushReact();
|
||||
await act(async () => {
|
||||
buttonContaining("Share selected sheets")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
@@ -3187,6 +3218,8 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
});
|
||||
await flushReact();
|
||||
await passAccessStep();
|
||||
await act(async () => { buttonByText("Advanced")!.dispatchEvent(new MouseEvent("click", { bubbles: true })); });
|
||||
await flushReact();
|
||||
await act(async () => {
|
||||
buttonContaining("Share selected sheets")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
@@ -3225,6 +3258,8 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
});
|
||||
await flushReact();
|
||||
await passAccessStep();
|
||||
await act(async () => { buttonByText("Advanced")!.dispatchEvent(new MouseEvent("click", { bubbles: true })); });
|
||||
await flushReact();
|
||||
await act(async () => {
|
||||
buttonContaining("Share selected sheets")?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
ToolConnection,
|
||||
ToolConnectionCredentialPolicy,
|
||||
} from "@paperclipai/shared";
|
||||
import { credentialConfigPath, getAvailableConnectionMethod, humanizeConnectionDisplayName } from "@paperclipai/shared";
|
||||
import { connectionCredentialConfigPath, credentialConfigPath, getAvailableConnectionMethod, humanizeConnectionDisplayName } from "@paperclipai/shared";
|
||||
import { toolsApi } from "@/api/tools";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -210,7 +210,7 @@ export function ReconnectCard({
|
||||
const methodUnavailable = connectionMethodUnavailable(connection, galleryEntry);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 rounded-lg border border-amber-500/50 bg-amber-500/10 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-col gap-4 rounded-lg border border-amber-500/50 bg-amber-500/10 px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-semibold text-amber-900 dark:text-amber-100">
|
||||
{methodUnavailable ? "Connection no longer supported" : oauth ? "Reconnect required" : "This app needs reconnecting"}
|
||||
@@ -289,25 +289,33 @@ function ReconnectForm({
|
||||
const method = galleryEntry && Array.isArray(galleryEntry.methods)
|
||||
? getAvailableConnectionMethod(galleryEntry, methodKey)
|
||||
: null;
|
||||
const fields = (method?.credentialFields ?? []).map((field) => ({
|
||||
const galleryFields = (method?.credentialFields ?? []).map((field) => ({
|
||||
...field,
|
||||
configPath: credentialConfigPath(field, method),
|
||||
helpUrl: method?.consoleLinks?.keys ?? method?.consoleLinks?.docs ?? "",
|
||||
}));
|
||||
const fields = galleryFields.length > 0 ? galleryFields : (connection.credentialRefs ?? [])
|
||||
.filter((ref) => ref.placement === "header" || ref.placement === "url")
|
||||
.map((ref) => ({
|
||||
configPath: connectionCredentialConfigPath(ref),
|
||||
label: ref.placement === "url" ? "MCP server URL" : ref.prefix === "Bearer " ? "App key" : ref.key ?? ref.name,
|
||||
helpUrl: "",
|
||||
required: true,
|
||||
}));
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
const [single, setSingle] = useState("");
|
||||
const usesGallery = fields.length > 0 && !!galleryEntry;
|
||||
const usesFields = fields.length > 0;
|
||||
|
||||
const reconnect = useMutation({
|
||||
mutationFn: () => {
|
||||
const credentialValues = usesGallery
|
||||
const credentialValues = usesFields
|
||||
? values
|
||||
: { "credentials.authorization": single.trim() };
|
||||
return toolsApi.reconnectConnection(connection.id, credentialValues);
|
||||
},
|
||||
onSuccess: (result) => {
|
||||
const healthy =
|
||||
result.connection.healthStatus === "healthy" || result.connection.healthStatus === "unknown";
|
||||
result.connection.healthStatus === "ok" || result.connection.healthStatus === "healthy" || result.connection.healthStatus === "unknown";
|
||||
if (healthy) {
|
||||
pushToast({
|
||||
title: "Reconnected",
|
||||
@@ -331,7 +339,7 @@ function ReconnectForm({
|
||||
}),
|
||||
});
|
||||
|
||||
const filled = usesGallery
|
||||
const filled = usesFields
|
||||
? fields.every((f) => f.required === false || (values[f.configPath]?.trim().length ?? 0) > 0)
|
||||
: single.trim().length > 0;
|
||||
|
||||
@@ -345,12 +353,13 @@ function ReconnectForm({
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{usesGallery ? (
|
||||
{usesFields ? (
|
||||
fields.map((field) => (
|
||||
<div key={field.configPath}>
|
||||
<label className="text-xs font-medium text-foreground">{field.label}</label>
|
||||
<Input
|
||||
type="password"
|
||||
aria-label={field.label}
|
||||
autoComplete="off"
|
||||
value={values[field.configPath] ?? ""}
|
||||
onChange={(e) => setValues({ ...values, [field.configPath]: e.target.value })}
|
||||
@@ -372,6 +381,7 @@ function ReconnectForm({
|
||||
) : (
|
||||
<Input
|
||||
type="password"
|
||||
aria-label="App key"
|
||||
autoComplete="off"
|
||||
value={single}
|
||||
onChange={(e) => setSingle(e.target.value)}
|
||||
|
||||
@@ -324,6 +324,29 @@ describe("TestPanel", () => {
|
||||
expect(container.textContent).toContain("Preview");
|
||||
});
|
||||
|
||||
it.each(["grant_credential_invalid", "oauth_insufficient_scope"])("guides %s failures to reconnect instead of changing action inputs", async (reasonCode) => {
|
||||
runTestCallMock.mockResolvedValue({
|
||||
decision: "allowed",
|
||||
invocationId: "inv-credential",
|
||||
error: { message: "The connection must be reconnected.", reasonCode },
|
||||
});
|
||||
await act(async () => renderPanel());
|
||||
await flushReact();
|
||||
const trigger = [...container.querySelectorAll("button")].find((b) => b.textContent?.includes("Read a sheet"));
|
||||
await act(async () => trigger!.click());
|
||||
await flushReact();
|
||||
await fillFormField("sheet-123");
|
||||
await clickByText("Run");
|
||||
await settle();
|
||||
|
||||
expect(container.textContent).toContain("After reconnecting, run this action again.");
|
||||
expect(container.textContent).not.toContain("Adjust the input above");
|
||||
expect(container.textContent).not.toContain("Check the field formats");
|
||||
expect(container.textContent).toContain(reasonCode === "grant_credential_invalid"
|
||||
? "Ask the connection owner to reconnect"
|
||||
: "allow the permissions required for this action");
|
||||
});
|
||||
|
||||
it("renders a failure card (not 'Worked') when an allowed call returns isError:true", async () => {
|
||||
// The gateway let the call through (decision:"allowed") but the upstream MCP
|
||||
// tool failed at the tool layer — the envelope carries isError + an error.
|
||||
|
||||
@@ -1360,6 +1360,7 @@ function ErrorResult({
|
||||
error: { message: string; reasonCode: string | null };
|
||||
}) {
|
||||
const hints = errorHints(error.message, error.reasonCode);
|
||||
const needsReconnect = isReconnectError(error.reasonCode);
|
||||
return (
|
||||
<div className="rounded-md border border-amber-500/40 bg-amber-500/5 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -1383,7 +1384,9 @@ function ErrorResult({
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-muted-foreground">Adjust the input above and try again.</p>
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
{needsReconnect ? "After reconnecting, run this action again." : "Adjust the input above and try again."}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Also visible in the{" "}
|
||||
<Link className="text-primary hover:underline" to="/activity?mode=agents&action=tool_">
|
||||
@@ -1653,7 +1656,17 @@ function safeStringify(value: unknown): string {
|
||||
* board-accepted copy-spec error-hint lookup (NOT_FOUND / PERMISSION_DENIED /
|
||||
* INVALID_ARGUMENT / RATE_LIMIT) with the locked generic fallback otherwise.
|
||||
*/
|
||||
function isReconnectError(reasonCode: string | null | undefined): boolean {
|
||||
return reasonCode === "grant_credential_invalid" || reasonCode === "oauth_insufficient_scope";
|
||||
}
|
||||
|
||||
export function errorHints(message: string, reasonCode: string | null | undefined): string[] {
|
||||
if (reasonCode === "grant_credential_invalid") {
|
||||
return ["Ask the connection owner to reconnect it from Connectors with a fresh key or server URL."];
|
||||
}
|
||||
if (reasonCode === "oauth_insufficient_scope") {
|
||||
return ["Reconnect the app and allow the permissions required for this action."];
|
||||
}
|
||||
const haystack = `${reasonCode ?? ""} ${message}`.toUpperCase();
|
||||
if (haystack.includes("NOT_FOUND")) {
|
||||
return [
|
||||
|
||||
Reference in new issue
Block a user