diff --git a/doc/connections/GOOGLE-WORKSPACE.md b/doc/connections/GOOGLE-WORKSPACE.md index 5153f64591..5067c7c8ee 100644 --- a/doc/connections/GOOGLE-WORKSPACE.md +++ b/doc/connections/GOOGLE-WORKSPACE.md @@ -64,7 +64,10 @@ The setup flow asks for the capability first. It then offers the authentication methods available for that capability: - **Connect with Paperclip** uses the Paperclip Cloud broker when that exact - profile is advertised by `GET https://my.paperclip.app/v1/connector/capabilities`. + profile is returned for this enrolled instance by the signed + `POST https://my.paperclip.app/v1/connector/instance-status` request. The + anonymous capabilities document is global discovery only and never enables + an internal-pilot method locally. - **Use your own Google OAuth app** uses customer-supplied OAuth credentials and the app definition's exact reviewed scopes. - **Use the Paperclip robot account** remains an additional Google Sheets-only diff --git a/packages/shared/src/app-definitions.test.ts b/packages/shared/src/app-definitions.test.ts index e32653b19b..655c793851 100644 --- a/packages/shared/src/app-definitions.test.ts +++ b/packages/shared/src/app-definitions.test.ts @@ -4,8 +4,29 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { APP_DEFINITIONS } from "./app-definitions.generated.js"; import { APP_STORE_DEFINITIONS, APP_STORE_HIDDEN_SLUGS, CONNECTABLE_APP_DEFINITIONS, appSupportsCatalogSetup, getAvailableConnectionMethod, getRecommendedConnectionMethod, recommendedDefaultsForApp, resolveConnectionMethodServerUrl } from "./app-definitions.js"; +import { GOOGLE_WORKSPACE_CONNECTOR_PROFILE_IDS, GOOGLE_WORKSPACE_CONNECTOR_PROFILES, type GoogleWorkspaceConnectorProfileId } from "./google-workspace-connectors.js"; import { BLOCKED_MCP_PROVIDERS, SELF_SERVE_MCP_CANDIDATES, SELF_SERVE_MCP_RESEARCH } from "./self-serve-mcp-research.js"; import { appDefinitionsSchema } from "./validators/app-definition.js"; + +const googleScope=(scope:string)=>`https://www.googleapis.com/auth/${scope}`; +const GOOGLE_WORKSPACE_PROFILE_EXPECTATIONS = [ + {profile:"gmail.read",appSlug:"gmail",serverUrl:"https://gmailmcp.googleapis.com/mcp/v1",capability:"read",riskTier:"S3",scopes:[googleScope("gmail.readonly")],writeTools:[]}, + {profile:"gmail.draft",appSlug:"gmail",serverUrl:"https://gmailmcp.googleapis.com/mcp/v1",capability:"draft",riskTier:"S4",scopes:[googleScope("gmail.readonly"),googleScope("gmail.compose")],writeTools:["create_draft"]}, + {profile:"drive.read",appSlug:"google-drive",serverUrl:"https://drivemcp.googleapis.com/mcp/v1",capability:"read",riskTier:"S3",scopes:[googleScope("drive.readonly")],writeTools:[]}, + {profile:"drive.write",appSlug:"google-drive",serverUrl:"https://drivemcp.googleapis.com/mcp/v1",capability:"write",riskTier:"S4",scopes:[googleScope("drive.readonly"),googleScope("drive.file")],writeTools:["copy_file","create_file"]}, + {profile:"docs.read",appSlug:"google-docs",serverUrl:"https://docsmcp.googleapis.com/mcp/v1",capability:"read",riskTier:"S3",scopes:[googleScope("drive.readonly"),googleScope("documents.readonly")],writeTools:[]}, + {profile:"docs.write",appSlug:"google-docs",serverUrl:"https://docsmcp.googleapis.com/mcp/v1",capability:"write",riskTier:"S4",scopes:[googleScope("drive.readonly"),googleScope("drive.file"),googleScope("documents")],writeTools:["update_doc"]}, + {profile:"sheets.read",appSlug:"google-sheets",serverUrl:"https://sheetsmcp.googleapis.com/mcp/v1",capability:"read",riskTier:"S3",scopes:[googleScope("drive.readonly"),googleScope("spreadsheets.readonly")],writeTools:[]}, + {profile:"sheets.write",appSlug:"google-sheets",serverUrl:"https://sheetsmcp.googleapis.com/mcp/v1",capability:"write",riskTier:"S4",scopes:[googleScope("drive.readonly"),googleScope("drive.file"),googleScope("spreadsheets")],writeTools:["update_spreadsheet","update_values","update_formulas","insert_dimension"]}, + {profile:"slides.read",appSlug:"google-slides",serverUrl:"https://slidesmcp.googleapis.com/mcp/v1",capability:"read",riskTier:"S3",scopes:[googleScope("drive.readonly"),googleScope("presentations.readonly")],writeTools:[]}, + {profile:"slides.write",appSlug:"google-slides",serverUrl:"https://slidesmcp.googleapis.com/mcp/v1",capability:"write",riskTier:"S4",scopes:[googleScope("drive.readonly"),googleScope("drive.file"),googleScope("presentations")],writeTools:["update_presentation"]}, + {profile:"calendar.read",appSlug:"google-calendar",serverUrl:"https://calendarmcp.googleapis.com/mcp/v1",capability:"read",riskTier:"S3",scopes:[googleScope("calendar.calendarlist.readonly"),googleScope("calendar.events.freebusy"),googleScope("calendar.events.readonly")],writeTools:[]}, + {profile:"calendar.write",appSlug:"google-calendar",serverUrl:"https://calendarmcp.googleapis.com/mcp/v1",capability:"write",riskTier:"S4",scopes:[googleScope("calendar.calendarlist.readonly"),googleScope("calendar.events.freebusy"),googleScope("calendar.events")],writeTools:["create_event","update_event","delete_event","respond_to_event"]}, + {profile:"chat.read",appSlug:"google-chat",serverUrl:"https://chatmcp.googleapis.com/mcp/v1",capability:"read",riskTier:"S3",scopes:[googleScope("chat.spaces.readonly"),googleScope("chat.memberships.readonly"),googleScope("chat.messages.readonly"),googleScope("chat.users.readstate.readonly")],writeTools:[]}, + {profile:"chat.write",appSlug:"google-chat",serverUrl:"https://chatmcp.googleapis.com/mcp/v1",capability:"write",riskTier:"S4",scopes:[googleScope("chat.spaces.readonly"),googleScope("chat.memberships.readonly"),googleScope("chat.messages.readonly"),googleScope("chat.users.readstate.readonly"),googleScope("chat.messages.create")],writeTools:["send_message"]}, + {profile:"people.read",appSlug:"google-people",serverUrl:"https://people.googleapis.com/mcp/v1",capability:"read",riskTier:"S3",scopes:[googleScope("directory.readonly"),googleScope("userinfo.profile"),googleScope("contacts.readonly")],writeTools:[]}, + {profile:"workspace-search.read",appSlug:"google-workspace-search",serverUrl:"https://workspacemcp.googleapis.com/mcp/v1",capability:"read",riskTier:"S3",scopes:[googleScope("gmail.readonly"),googleScope("drive.readonly"),googleScope("calendar.readonly"),googleScope("chat.messages.readonly")],writeTools:[]}, +] as const satisfies ReadonlyArray<{profile:GoogleWorkspaceConnectorProfileId;appSlug:string;serverUrl:string;capability:"read"|"write"|"draft";riskTier:"S3"|"S4";scopes:readonly string[];writeTools:readonly string[]}>; describe("AppDefinition catalog",()=>{ it("validates all Wave 1 definitions",()=>expect(()=>appDefinitionsSchema.parse(APP_DEFINITIONS)).not.toThrow()); it("contains every established provider plus the reviewed self-serve catalog",()=>{ @@ -100,9 +121,16 @@ describe("AppDefinition catalog",()=>{ expect(getAvailableConnectionMethod(drive)?.key).toBe("customer-write-oauth"); expect(getAvailableConnectionMethod(gmail)?.key).toBe("customer-draft-oauth"); expect(getRecommendedConnectionMethod(drive.methods.filter((candidate)=>candidate.ownershipModes.includes("customer")))?.key).toBe("customer-write-oauth"); + expect(getRecommendedConnectionMethod(gmail.methods.filter((candidate)=>[ + "paperclip-read","customer-read-oauth","customer-draft-oauth", + ].includes(candidate.key)))?.key).toBe("paperclip-read"); + expect(getRecommendedConnectionMethod(gmail.methods.filter((candidate)=>candidate.capabilityProfile?.key==="draft"))?.key).toBe("paperclip-draft"); + expect(getRecommendedConnectionMethod(gmail.methods.filter((candidate)=> + candidate.capabilityProfile?.key==="draft"&&candidate.ownershipModes.includes("customer") + ))?.key).toBe("customer-draft-oauth"); }); it("explains Google Workspace Developer Preview enrollment before connection",()=>{ - const googleWorkspaceMcpSlugs=["gmail","google-drive","google-docs","google-slides","google-calendar","google-chat","google-people","google-workspace-search"]; + const googleWorkspaceMcpSlugs=["gmail","google-drive","google-docs","google-sheets","google-slides","google-calendar","google-chat","google-people","google-workspace-search"]; for(const slug of googleWorkspaceMcpSlugs){ const prerequisite=APP_DEFINITIONS.find((app)=>app.slug===slug)?.setupPrerequisite; expect(prerequisite?.actionUrl,slug).toBe("https://developers.google.com/workspace/preview"); @@ -158,41 +186,42 @@ describe("AppDefinition catalog",()=>{ for(const candidate of SELF_SERVE_MCP_CANDIDATES)expect(appSupportsCatalogSetup(definitions.get(candidate.slug))).toBe(true); for(const blocked of BLOCKED_MCP_PROVIDERS)expect(definitions.has(blocked.slug)).toBe(false); }); - it("ships each Google Workspace surface as an independent personal OAuth app",()=>{ - const expected={ - gmail:"https://gmailmcp.googleapis.com/mcp/v1", - "google-drive":"https://drivemcp.googleapis.com/mcp/v1", - "google-docs":"https://docsmcp.googleapis.com/mcp/v1", - "google-sheets":"https://sheetsmcp.googleapis.com/mcp/v1", - "google-slides":"https://slidesmcp.googleapis.com/mcp/v1", - "google-calendar":"https://calendarmcp.googleapis.com/mcp/v1", - "google-chat":"https://chatmcp.googleapis.com/mcp/v1", - "google-people":"https://people.googleapis.com/mcp/v1", - "google-workspace-search":"https://workspacemcp.googleapis.com/mcp/v1", - } as const; - for(const [slug,serverUrl] of Object.entries(expected)){ - const app=APP_DEFINITIONS.find((candidate)=>candidate.slug===slug); - expect(app,slug).toBeTruthy(); - expect(app?.methods.some((method)=>method.oauthStrategy==="paperclip_cloud_connector")).toBe(true); - for(const method of app?.methods.filter((candidate)=>candidate.auth==="oauth")??[]){ - expect(method.grantKinds,`${slug}:${method.key}`).toEqual(["user"]); - expect(method.defaults?.serverUrl,`${slug}:${method.key}`).toBe(serverUrl); - expect(method.capabilityProfile,`${slug}:${method.key}`).toBeTruthy(); - } + it("keeps all Google Workspace profiles aligned with their app, endpoint, scopes, ownership, risk, and write policy",()=>{ + expect(GOOGLE_WORKSPACE_CONNECTOR_PROFILE_IDS).toEqual(GOOGLE_WORKSPACE_PROFILE_EXPECTATIONS.map((entry)=>entry.profile)); + expect(Object.keys(GOOGLE_WORKSPACE_CONNECTOR_PROFILES)).toEqual([...GOOGLE_WORKSPACE_CONNECTOR_PROFILE_IDS]); + for(const expected of GOOGLE_WORKSPACE_PROFILE_EXPECTATIONS){ + expect(GOOGLE_WORKSPACE_CONNECTOR_PROFILES[expected.profile],expected.profile).toEqual({ + appSlug:expected.appSlug, + serverUrl:expected.serverUrl, + scopes:expected.scopes, + writeTools:expected.writeTools, + }); + const app=APP_DEFINITIONS.find((candidate)=>candidate.slug===expected.appSlug); + const managed=app?.methods.find((method)=>method.connectorProfile===expected.profile); + expect(managed,expected.profile).toMatchObject({ + auth:"oauth", + oauthStrategy:"paperclip_cloud_connector", + connectorProfile:expected.profile, + capabilityProfile:{key:expected.capability}, + grantKinds:["user"], + ownershipModes:["platform_shared"], + defaults:{serverUrl:expected.serverUrl,scopesHint:expected.scopes}, + riskTier:expected.riskTier, + }); + expect(managed?.riskTier,`${expected.profile}:write-risk`).toBe(expected.writeTools.length>0?"S4":"S3"); + const customer=app?.methods.find((method)=> + method.auth==="oauth" + &&method.oauthStrategy===undefined + &&method.capabilityProfile?.key===expected.capability + ); + expect(customer,`${expected.profile}:customer-fallback`).toMatchObject({ + grantKinds:["user"], + ownershipModes:["customer"], + defaults:{serverUrl:expected.serverUrl,scopesHint:expected.scopes}, + riskTier:expected.riskTier, + }); } }); - it("advertises managed Gmail methods with their profile-scoped grants",()=>{ - const gmail=APP_DEFINITIONS.find((app)=>app.slug==="gmail"); - const managedMethods=gmail?.methods.filter((method)=>method.oauthStrategy==="paperclip_cloud_connector")??[]; - expect(managedMethods.map((method)=>method.key)).toEqual(["paperclip-read","paperclip-draft"]); - expect(managedMethods.map((method)=>[method.connectorProfile,method.defaults?.scopesHint])).toEqual([ - ["gmail.read",["https://www.googleapis.com/auth/gmail.readonly"]], - ["gmail.draft",[ - "https://www.googleapis.com/auth/gmail.readonly", - "https://www.googleapis.com/auth/gmail.compose", - ]], - ]); - }); it("configures Shopify's current UCP and compatibility MCP methods without OAuth",()=>{const shopify=APP_DEFINITIONS.find((app)=>app.slug==="shopify");expect(shopify?.methods.map((method)=>method.key)).toEqual(["ucp-commerce","storefront-mcp"]);const ucp=shopify?.methods[0];const compatibility=shopify?.methods[1];expect(ucp).toMatchObject({auth:"none",defaults:{serverUrlTemplate:"https://{storeDomain}/api/ucp/mcp",toolArgumentDefaults:{meta:{"ucp-agent":{profile:"https://shopify.dev/ucp/agent-profiles/examples/2026-04-08/valid-with-capabilities.json"}}}},tenantFields:[expect.objectContaining({key:"storeDomain",required:true})]});expect(compatibility).toMatchObject({auth:"none",defaults:{serverUrlTemplate:"https://{storeDomain}/api/mcp"}});expect(resolveConnectionMethodServerUrl(ucp!,{storeDomain:"paperclip-demo.myshopify.com"})).toBe("https://paperclip-demo.myshopify.com/api/ucp/mcp");expect(resolveConnectionMethodServerUrl(compatibility!,{storeDomain:"paperclip-demo.myshopify.com"})).toBe("https://paperclip-demo.myshopify.com/api/mcp");expect(resolveConnectionMethodServerUrl(ucp!,{})).toBeNull();expect(shopify?.setupPrerequisite).toMatchObject({title:"Launch the storefront before connecting",actionUrl:"https://admin.shopify.com/"});expect(shopify?.setupPrerequisite?.steps?.join(" ")).toContain("Storefront visibility to Public")}); it("offers PostHog OAuth and API-key methods with zero-config defaults and advanced narrowing",()=>{const posthog=APP_DEFINITIONS.find((app)=>app.slug==="posthog");expect(posthog?.methods.map((method)=>method.key)).toEqual(["mcp-oauth","mcp-api-key"]);for(const method of posthog?.methods??[]){const projectField=method.tenantFields?.find((field)=>field.key==="projectId");expect(method.riskTier).toBe("S3");expect(method.tenantFields?.find((field)=>field.key==="readOnly")).toMatchObject({defaultValue:false,advanced:true});expect(projectField).toMatchObject({advanced:true,transport:{location:"header",name:"x-posthog-project-id"}});expect(projectField?.required).not.toBe(true);expect(method.tenantFields?.filter((field)=>field.advanced).map((field)=>field.key)).toEqual(["projectId","readOnly","features","tools"]);expect(method.tenantFields?.find((field)=>field.key==="mode")).toMatchObject({hidden:true,defaultValue:"tools",transport:{location:"query",name:"mode"}});expect(method.configRequirements).toBeUndefined();expect(method.requiredResourceFilters).toBeUndefined();expect(method.guidanceMd).toContain("optional advanced controls")}}); it("requires only reviewed provider or safety-boundary configuration on the default path",()=>{const required=APP_DEFINITIONS.flatMap((app)=>app.methods.flatMap((method)=>[...(method.tenantFields??[]),...(method.extensionFields??[])].filter((field)=>field.required&&field.advanced!==true&&!field.hidden).map((field)=>`${app.slug}:${method.key}:${field.key}`))).sort();expect(required).toEqual(["clickhouse:mcp-oauth:serviceId","shopify:storefront-mcp:storeDomain","shopify:ucp-commerce:storeDomain","supabase:mcp-api-key:projectRef","supabase:mcp-oauth:projectRef"])}); diff --git a/packages/shared/src/app-definitions.ts b/packages/shared/src/app-definitions.ts index 586e68f5e3..2f3df52718 100644 --- a/packages/shared/src/app-definitions.ts +++ b/packages/shared/src/app-definitions.ts @@ -123,10 +123,24 @@ export function getAvailableConnectionMethods(app: AppDefinition): ConnectionMet export function getRecommendedConnectionMethod( methods: readonly ConnectionMethodDef[], ): ConnectionMethodDef | null { - return methods.find((method) => { + const recommendedCapability = (candidates: readonly ConnectionMethodDef[]) => candidates.find((method) => { const capabilityKey = method.capabilityProfile?.key; return capabilityKey === "write" || capabilityKey === "draft"; - }) ?? methods[0] ?? null; + }); + const managedMethods = methods.filter((method) => + method.oauthStrategy === "paperclip_cloud_connector" + || 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. + return recommendedCapability(managedMethods) + ?? managedMethods[0] + ?? recommendedCapability(methods) + ?? methods[0] + ?? null; } export function getAvailableConnectionMethod( diff --git a/packages/shared/src/app-definitions/google-sheets.json b/packages/shared/src/app-definitions/google-sheets.json index b709471505..fa63fe17a8 100644 --- a/packages/shared/src/app-definitions/google-sheets.json +++ b/packages/shared/src/app-definitions/google-sheets.json @@ -17,6 +17,17 @@ "https://sheets.google.com/*" ], "docsUrl": "https://developers.google.com/workspace/sheets/api/reference/mcp", + "setupPrerequisite": { + "title": "Google Developer Preview access required", + "description": "Google must register both the Workspace email used to authorize Paperclip and the Google Cloud project that owns the OAuth client. Registration is limited to those emails and projects; it does not enable unrelated Paperclip customers.", + "steps": [ + "Apply with the Workspace email that will sign in and the Google Cloud project that owns the OAuth client.", + "Wait for the Google Group notification and then Google's final project-registration email, usually within a couple of days.", + "Add every additional tester email or Cloud project through Google's member request forms before connecting." + ], + "actionLabel": "Apply or verify Developer Preview enrollment", + "actionUrl": "https://developers.google.com/workspace/preview" + }, "redirectConstraints": "https-or-loopback-http", "methods": [ { diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts index 76c456a1bd..2a897f1dbe 100644 --- a/server/src/__tests__/tool-access-service.test.ts +++ b/server/src/__tests__/tool-access-service.test.ts @@ -40,7 +40,12 @@ import { toolStdioCommandTemplates, } from "@paperclipai/db"; import { and, eq, inArray, sql } from "drizzle-orm"; -import { APP_STORE_HIDDEN_SLUGS, getConnectableAppDefinition } from "@paperclipai/shared"; +import { + APP_STORE_HIDDEN_SLUGS, + GOOGLE_WORKSPACE_CONNECTOR_PROFILES, + getConnectableAppDefinition, + type GoogleWorkspaceConnectorProfileId, +} from "@paperclipai/shared"; import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase, @@ -62,7 +67,6 @@ import { errorHandler } from "../middleware/index.js"; import type { ComposioClient } from "../services/composio.js"; import type { VercelConnectClient } from "../services/vercel-connect.js"; import { - GMAIL_CONNECTOR_SCOPES, type PaperclipCloudConnector, } from "../services/paperclip-cloud-connector.js"; @@ -85,23 +89,29 @@ function createTestToolAccessService( }); } -function fakeGmailConnector(companyId: string, userId: string): PaperclipCloudConnector { +function fakeGoogleWorkspaceConnector( + companyId: string, + userId: string, + profile: GoogleWorkspaceConnectorProfileId = "gmail.draft", +): PaperclipCloudConnector { + const profileDefinition = GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profile]; + const tokenPrefix = profile.split(".")[0]!; const credentials = { v: 1 as const, - accessToken: "gmail-access-token", - refreshToken: "gmail-refresh-token", + accessToken: `${tokenPrefix}-access-token`, + refreshToken: `${tokenPrefix}-refresh-token`, tokenType: "Bearer", accessTokenExpiresAt: new Date(Date.now() + 3_600_000).toISOString(), - scopes: [...GMAIL_CONNECTOR_SCOPES], + scopes: [...profileDefinition.scopes], subject: userId, companyId, instanceId: "test-instance", environment: "development" as const, provider: "google" as const, - profile: "gmail.draft", + profile, }; return { - getCapabilities: vi.fn(async () => ["gmail.draft" as const]), + getCapabilities: vi.fn(async () => [profile]), startAuthorization: vi.fn(async ({ returnState }) => ({ authorizationUrl: `https://accounts.google.com/o/oauth2/v2/auth?state=${encodeURIComponent(returnState)}`, expiresAt: new Date(Date.now() + 600_000).toISOString(), @@ -112,6 +122,10 @@ function fakeGmailConnector(companyId: string, userId: string): PaperclipCloudCo }; } +function fakeGmailConnector(companyId: string, userId: string): PaperclipCloudConnector { + return fakeGoogleWorkspaceConnector(companyId, userId); +} + function createToolGatewayService( db: ReturnType, options: NonNullable[1]> = {}, @@ -281,8 +295,9 @@ function createRouteApp( actor?: Express.Request["actor"], toolGateway?: ToolGatewayService, deployment?: { - deploymentMode: "local_trusted" | "authenticated"; - deploymentExposure: "private" | "public"; + deploymentMode?: "local_trusted" | "authenticated"; + deploymentExposure?: "private" | "public"; + paperclipCloudConnector?: PaperclipCloudConnector | null; }, useProtocolFixtureTransport = true, ) { @@ -3674,6 +3689,48 @@ describeEmbeddedPostgres("tool access service", () => { ); }); + it("exposes managed Google methods only for profiles signed for this enrolled instance", async () => { + const company = await createCompany(db); + const userId = `gallery-pilot-${randomUUID()}`; + const pilotConnector = fakeGoogleWorkspaceConnector(company.id, userId, "gmail.read"); + const nonPilotConnector: PaperclipCloudConnector = { + ...pilotConnector, + getCapabilities: vi.fn(async () => []), + }; + + const nonPilot = await request(createRouteApp( + db, + boardSessionActor(company.id, "owner", userId), + undefined, + { paperclipCloudConnector: nonPilotConnector }, + )).get(`/api/companies/${company.id}/tools/gallery`); + expect(nonPilot.status).toBe(200); + const nonPilotGmail = nonPilot.body.apps.find((app: { slug: string }) => app.slug === "gmail"); + expect(nonPilotGmail.ownershipAvailability.platform_shared).toBe(false); + expect(nonPilotGmail.methods.some((method: { oauthStrategy?: string }) => + method.oauthStrategy === "paperclip_cloud_connector" + )).toBe(false); + expect(nonPilotGmail.methods.map((method: { key: string }) => method.key)).toEqual([ + "customer-read-oauth", + "customer-draft-oauth", + ]); + + const pilot = await request(createRouteApp( + db, + boardSessionActor(company.id, "owner", userId), + undefined, + { paperclipCloudConnector: pilotConnector }, + )).get(`/api/companies/${company.id}/tools/gallery`); + expect(pilot.status).toBe(200); + const pilotGmail = pilot.body.apps.find((app: { slug: string }) => app.slug === "gmail"); + expect(pilotGmail.ownershipAvailability.platform_shared).toBe(true); + expect(pilotGmail.methods.map((method: { key: string }) => method.key)).toEqual([ + "paperclip-read", + "customer-read-oauth", + "customer-draft-oauth", + ]); + }); + it("preflights only public Jira metadata without credentials or OAuth registration", async () => { const requests: Array<{ url: string; method: string; hasAuthorization: boolean }> = []; const service = createTestToolAccessService(db, { @@ -4883,6 +4940,131 @@ describeEmbeddedPostgres("tool access service", () => { } }, 15_000); + it("routes a managed Drive callback into the personal vault, filtered catalog, and provider-specific activity", async () => { + const company = await createCompany(db); + const userId = `drive-member-${randomUUID()}`; + await grantBoardUser(db, company.id, userId, [], "owner"); + const profile = "drive.read" as const; + const connector = fakeGoogleWorkspaceConnector(company.id, userId, profile); + const service = createTestToolAccessService(db, { paperclipCloudConnector: connector }); + const actor = { actorType: "user" as const, actorId: userId }; + const driveDefinition = getConnectableAppDefinition("google-drive")!; + const previousOwnershipAvailability = driveDefinition.ownershipAvailability; + driveDefinition.ownershipAvailability = { ...previousOwnershipAvailability, platform_shared: true }; + mockToolsList([ + { name: "search_files", annotations: { readOnlyHint: true } }, + { name: "create_file", annotations: { readOnlyHint: false } }, + ]); + + try { + const connected = await service.connectGalleryApp(company.id, { + galleryKey: "google-drive", + connectionMethodKey: "paperclip-read", + grantKind: "user", + name: "Drive managed read", + }, actor); + const started = await service.startOAuth(company.id, connected.connectionId, { + redirectUri: "https://paperclip.example/api/tools/oauth/cloud-connector/callback", + actor, + }); + const state = new URL(started.authorizationUrl).searchParams.get("state")!; + const app = createRouteApp( + db, + boardSessionActor(company.id, "owner", userId), + undefined, + { paperclipCloudConnector: connector }, + ); + + const callback = await request(app) + .get("/api/tools/oauth/cloud-connector/callback") + .query({ state, claim_id: "drive-claim" }) + .set("accept", "application/json"); + + expect(callback.status).toBe(200); + expect(connector.startAuthorization).toHaveBeenCalledWith(expect.objectContaining({ + companyId: company.id, + subject: userId, + profile, + })); + expect(connector.claim).toHaveBeenCalledWith(expect.objectContaining({ + companyId: company.id, + subject: userId, + profile, + claimId: "drive-claim", + redemptionId: state, + })); + expect(callback.body.connection).toMatchObject({ + status: "active", + enabled: true, + healthStatus: "ok", + config: { + sourceTemplateKey: "google-drive", + oauth: { + strategy: "paperclip_cloud_connector", + provider: "google-drive", + connectorProfile: profile, + resource: GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profile].serverUrl, + scopes: [...GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profile].scopes], + }, + }, + }); + expect(callback.body.catalog).toEqual(expect.arrayContaining([ + expect.objectContaining({ toolName: "search_files", status: "quarantined", riskLevel: "read" }), + expect.objectContaining({ toolName: "create_file", status: "disabled", riskLevel: "write" }), + ])); + + const [grant] = await db.select().from(connectionGrants).where(and( + eq(connectionGrants.connectionId, connected.connectionId), + eq(connectionGrants.kind, "user"), + eq(connectionGrants.subjectUserId, userId), + )); + expect(grant).toMatchObject({ + status: "active", + providerTenant: { + name: "Google Drive", + oauth: { + strategy: "paperclip_cloud_connector", + scopes: [...GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profile].scopes], + }, + }, + }); + expect(grant!.credentialSecretRefs.map((ref) => ref.configPath).sort()).toEqual([ + "oauth.access_token", + "oauth.refresh_token", + ]); + const secrets = await db.select().from(companySecrets).where(inArray( + companySecrets.id, + grant!.credentialSecretRefs.map((ref) => ref.secretId), + )); + expect(secrets).toHaveLength(2); + expect(secrets).toEqual(expect.arrayContaining([ + expect.objectContaining({ companyId: company.id, scope: "user", ownerUserId: userId, provider: "local_encrypted" }), + ])); + const versions = await db.select().from(companySecretVersions).where(inArray( + companySecretVersions.secretId, + grant!.credentialSecretRefs.map((ref) => ref.secretId), + )); + expect(versions).toHaveLength(2); + expect(JSON.stringify(versions)).not.toContain("drive-access-token"); + expect(JSON.stringify(versions)).not.toContain("drive-refresh-token"); + + const [activity] = await db.select().from(activityLog).where(and( + eq(activityLog.entityId, connected.connectionId), + eq(activityLog.action, "tool_app.oauth_connected"), + )); + expect(activity?.details).toMatchObject({ + applicationId: callback.body.application.id, + catalogEntryCount: 2, + provider: "google-drive", + profile, + }); + expect(JSON.stringify(activity?.details)).not.toContain(userId); + expect(JSON.stringify(activity?.details)).not.toContain("token"); + } finally { + driveDefinition.ownershipAvailability = previousOwnershipAvailability; + } + }); + it("keeps brokered OAuth state retryable until credentials are durably stored", async () => { const company = await createCompany(db); const userId = `gmail-retry-${randomUUID()}`; diff --git a/server/src/routes/tool-access.ts b/server/src/routes/tool-access.ts index 2c1a0a5944..6f20e9a07f 100644 --- a/server/src/routes/tool-access.ts +++ b/server/src/routes/tool-access.ts @@ -5,6 +5,8 @@ import { and, eq, or } from "drizzle-orm"; import { APP_STORE_DEFINITIONS, DEFAULT_OWNERSHIP_AVAILABILITY, + GOOGLE_WORKSPACE_CONNECTOR_PROFILES, + isGoogleWorkspaceConnectorProfileId, TOOL_ACTION_REQUEST_STATUSES, type DeploymentExposure, type DeploymentMode, @@ -55,6 +57,7 @@ import type { ComposioClient } from "../services/composio.js"; import type { VercelConnectClient } from "../services/vercel-connect.js"; import { isPaperclipCloudConnectorStrategy, + type PaperclipCloudConnector, paperclipCloudConnectorCapabilitiesFromEnv, } from "../services/paperclip-cloud-connector.js"; import { @@ -194,6 +197,7 @@ export function toolAccessRoutes( remoteHttpRequest?: NonNullable[1]>["remoteHttpRequest"]; composioClientFactory?: (apiKey: string) => ComposioClient; vercelConnectClient?: VercelConnectClient | null; + paperclipCloudConnector?: PaperclipCloudConnector | null; connectionIntentHeartbeat?: Pick; } = {}, ) { @@ -679,7 +683,12 @@ function connectorEnrollmentPrincipal(req: Request): string { assertBoard(req); const companyId = req.params.companyId as string; assertCompanyAccess(req, companyId); - const googleConnectorProfiles = new Set(await paperclipCloudConnectorCapabilitiesFromEnv()); + const advertisedProfiles = options.paperclipCloudConnector === undefined + ? await paperclipCloudConnectorCapabilitiesFromEnv() + : options.paperclipCloudConnector + ? await options.paperclipCloudConnector.getCapabilities() + : []; + const googleConnectorProfiles = new Set(advertisedProfiles); const vercelConnect = vercelConnectIntegrationStatus(); res.json({ capabilities: await describeConnectionCreateCapabilities(req, companyId), @@ -944,6 +953,19 @@ function connectorEnrollmentPrincipal(req: Request): string { error, actor: getActorInfo(req), }); + const pendingConfig = pendingConnection.config ?? {}; + const oauthConfig = pendingConfig.oauth && typeof pendingConfig.oauth === "object" + ? pendingConfig.oauth as Record + : null; + const connectorProfileValue = typeof oauthConfig?.connectorProfile === "string" + ? oauthConfig.connectorProfile + : null; + const connectorProfile = connectorProfileValue && isGoogleWorkspaceConnectorProfileId(connectorProfileValue) + ? connectorProfileValue + : null; + const connectorDefinition = connectorProfile + ? GOOGLE_WORKSPACE_CONNECTOR_PROFILES[connectorProfile] + : null; await logActivity(db, { companyId: result.connection.companyId, actorType: "user", @@ -951,7 +973,12 @@ function connectorEnrollmentPrincipal(req: Request): string { action: "tool_app.oauth_connected", entityType: "tool_connection", entityId: result.connection.id, - details: { applicationId: result.application.id, catalogEntryCount: result.catalog.length, provider: "gmail" }, + details: { + applicationId: result.application.id, + catalogEntryCount: result.catalog.length, + provider: connectorDefinition?.appSlug ?? "google", + ...(connectorDefinition ? { profile: connectorProfile } : {}), + }, }); if (acceptsHtml && pendingConnectionIntent && pendingState.interactionId && req.actor.userId) { await finishConnectionIntentOAuth({ diff --git a/server/src/services/paperclip-cloud-connector.test.ts b/server/src/services/paperclip-cloud-connector.test.ts index fb736a3a2a..d75be2d577 100644 --- a/server/src/services/paperclip-cloud-connector.test.ts +++ b/server/src/services/paperclip-cloud-connector.test.ts @@ -152,18 +152,48 @@ describe("Paperclip Cloud connector", () => { it("accepts only the current capability protocol and known profiles", async () => { const keys = config(); - const request = vi.fn(async () => Response.json({ - providers: [{ key: "google", profiles: [ - { key: "gmail.read", enabled: true }, - { key: "drive.write", enabled: true }, - { key: "unknown.profile", enabled: true }, - { key: "gmail.draft", enabled: false }, - ] }], - })); + const request = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)) as { request: string }; + const [, encodedClaims] = body.request.split("."); + const claims = JSON.parse(Buffer.from(encodedClaims!, "base64url").toString("utf8")); + expect(claims).toMatchObject({ + iss: instanceId, + aud: "https://my.example.test/v1/connector/instance-status", + sub: "instance-capabilities", + cid: "instance-capabilities", + env: "staging", + op: "status", + }); + expect(claims).not.toHaveProperty("prv"); + expect(claims).not.toHaveProperty("prf"); + expect(claims).not.toHaveProperty("scp"); + return Response.json({ + active: true, + status: "active", + profiles: ["gmail.read", "drive.write", "unknown.profile", "gmail.read"], + }); + }); const connector = createPaperclipCloudConnector({ config: keys.config, request: request as typeof fetch }); await expect(connector.getCapabilities()).resolves.toEqual(["gmail.read", "drive.write"]); }); + it("fails capability discovery closed for inactive, legacy, malformed, or rejected status responses", async () => { + const keys = config(); + const responses = [ + Response.json({ active: false, status: "suspended", profiles: ["gmail.read"] }), + Response.json({ active: true, status: "active" }), + Response.json({ active: true, status: "active", profiles: "gmail.read" }), + new Response("detail must not escape", { status: 403 }), + ]; + for (const response of responses) { + const connector = createPaperclipCloudConnector({ + config: keys.config, + request: vi.fn(async () => response) as typeof fetch, + }); + await expect(connector.getCapabilities()).resolves.toEqual([]); + } + }); + it("checks Cloud enrollment status with an instance-only signed request", async () => { const keys = config(); const request = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => { diff --git a/server/src/services/paperclip-cloud-connector.ts b/server/src/services/paperclip-cloud-connector.ts index 3c446639a4..b2df0185ad 100644 --- a/server/src/services/paperclip-cloud-connector.ts +++ b/server/src/services/paperclip-cloud-connector.ts @@ -293,22 +293,19 @@ export function createPaperclipCloudConnector(input: { throw new PaperclipCloudConnectorError("Paperclip Cloud connector returned an invalid instance status", "CONNECTOR_BAD_RESPONSE"); }, async getCapabilities(): Promise { - const endpoint = new URL("/v1/connector/capabilities", `${config.baseUrl}/`).toString(); - let response: Response; + let response: ConnectorResponse; try { - response = await request(endpoint, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(5_000) }); + response = await call("status", { + subject: "instance-capabilities", + companyId: "instance-capabilities", + }); } catch { return []; } - if (!response.ok) return []; - const payload = await response.json().catch(() => null) as ConnectorResponse | null; - if (!Array.isArray(payload?.providers)) return []; - const google = payload.providers.find((value) => isRecord(value) && value.key === "google"); - if (!isRecord(google) || !Array.isArray(google.profiles)) return []; - return google.profiles.flatMap((value) => { - if (!isRecord(value) || value.enabled !== true || typeof value.key !== "string") return []; - return isGoogleWorkspaceConnectorProfileId(value.key) ? [value.key] : []; - }); + if (response.active !== true || response.status !== "active" || !Array.isArray(response.profiles)) return []; + return [...new Set(response.profiles.flatMap((value) => + typeof value === "string" && isGoogleWorkspaceConnectorProfileId(value) ? [value] : [] + ))]; }, async startAuthorization(values: { subject: string; companyId: string; profile?: GoogleWorkspaceConnectorProfileId; returnUri: string; returnState: string }) { const profile = values.profile ?? "gmail.draft"; @@ -367,13 +364,7 @@ export async function paperclipCloudConnectorCapabilitiesFromEnv( const key = `${config.baseUrl}|${config.instanceId}|${config.environment}`; if (capabilityCache?.key === key && capabilityCache.expiresAt > Date.now()) return capabilityCache.profiles; const connector = createPaperclipCloudConnector({ config }); - let status: "active" | "suspended" | "removed"; - try { - status = await connector.getInstanceStatus(); - } catch { - return []; - } - const profiles = status === "active" ? await connector.getCapabilities() : []; + const profiles = await connector.getCapabilities(); capabilityCache = { key, expiresAt: Date.now() + 60_000, profiles }; return profiles; }