diff --git a/doc/connections/CONNECTOR-PLAYBOOK.md b/doc/connections/CONNECTOR-PLAYBOOK.md index bd4e49f78c..9cbbb88db3 100644 --- a/doc/connections/CONNECTOR-PLAYBOOK.md +++ b/doc/connections/CONNECTOR-PLAYBOOK.md @@ -746,9 +746,12 @@ Walk the user path: For OAuth, the instance callback must be browser-reachable and must match the provider registration. Loopback HTTP is acceptable only when provider and -Paperclip redirect policies permit it. A worktree exposed through HTTPS needs a -unique, correct `PAPERCLIP_PUBLIC_URL`; internal service hostnames are not valid -browser callback origins. +Paperclip redirect policies permit it. Browser-started setup on an authenticated +private instance automatically uses the same-origin HTTPS address that served +the setup page, including a Tailscale Serve address; the request must pass the +hostname and board-mutation guards. An explicit `PAPERCLIP_PUBLIC_URL` remains +available for non-browser starts and unusual proxy topologies. Internal service +hostnames are not valid browser callback origins. Use the browser signed-in session only for an explicitly authorized live proof. Do not inspect cookies, storage, saved passwords, or unrelated account data. @@ -1782,8 +1785,8 @@ plain-HTTP non-loopback origins. loopback HTTP (Notion's redirect-URI rule). A plain-HTTP non-loopback origin gets "This provider requires an HTTPS or loopback origin. Configure TLS before connecting." — add TLS first (e.g. a tailscale cert, as - paperclip-dev did). The `enableApps` experimental setting must be on for - `/apps/*` routes. The connecting user must be allowed to install + paperclip-dev did). Apps is a standard product surface and `/apps/*` routes + are always available. The connecting user must be allowed to install integrations in their Notion workspace. - How to verify: visit `/PAP/apps/connect?source=notion`, complete the Notion consent flow, and land on the wizard's actions step listing `notion-*` diff --git a/doc/connections/GMAIL.md b/doc/connections/GMAIL.md index 60332d488a..1cdbf01536 100644 --- a/doc/connections/GMAIL.md +++ b/doc/connections/GMAIL.md @@ -217,7 +217,7 @@ sequenceDiagram U->>P: Return to exact enrolled instance URL P->>C: Signed one-time claim C-->>P: Instance-encrypted token response - P->>V: Encrypt tokens and bind them to the user's grant + P->>V: Encrypt tokens and bind them to the chosen user or organization grant ``` Before an instance can create a session: @@ -232,8 +232,11 @@ Before an instance can create a session: initiating administrator must complete the return callback. 3. Paperclip Cloud binds the account, opaque instance id, both public keys, deployment environment, and exact allowed browser return origins. -4. Tailscale HTTPS origins are allowed only when explicitly enrolled. Loopback - HTTP is development-only. Other plaintext origins are rejected. +4. On authenticated private instances, the setup request supplies its verified + same-origin HTTPS address and enrollment binds it automatically. This makes a + Tailscale HTTPS setup config-free while still rejecting a bare or mismatched + `Host` header. Loopback HTTP is development-only; other plaintext origins are + rejected. 5. Create, claim, refresh, and supported revoke requests are signed, audience-bound, timestamped, and protected by a one-time `jti` replay cache. @@ -296,9 +299,11 @@ keys before they deploy a binary that enables the Cloud connector. ## Paperclip access defaults -The first Gmail release is personal-only: +Gmail uses the same credential ownership choice as the rest of the Apps setup: -- **Just me** is the only credential ownership choice. +- **Just me** stores the Gmail credential on the connecting user's grant. +- **Any human in the company** stores it on the default organization grant so a + deliberately shared mailbox or Workspace account can back company-wide use. - The disclosure states that Gmail access can search/read mail and create drafts. Sending mail is not enabled. - A user grant does not automatically authorize an agent. The user must also diff --git a/doc/connections/GOOGLE-WORKSPACE.md b/doc/connections/GOOGLE-WORKSPACE.md index 5067c7c8ee..511f3f2741 100644 --- a/doc/connections/GOOGLE-WORKSPACE.md +++ b/doc/connections/GOOGLE-WORKSPACE.md @@ -73,9 +73,12 @@ methods available for that capability: - **Use the Paperclip robot account** remains an additional Google Sheets-only option for explicitly shared spreadsheets. -OAuth grants begin as personal connections. Existing promotion controls may -later make an eligible connection available to the company without silently -changing the underlying Google principal. +Before Google consent, the setup flow asks whether the credential is for just +the connecting user or for any human in the company. A personal choice stores +the tokens only on that user's grant. A company choice stores them on the +default organization grant, while still recording which signed-in Google +principal completed consent so refresh and reconnect stay bound to that +principal. ## Broker profiles @@ -121,7 +124,10 @@ path. Cloud-hosted stacks receive these values through the existing per-stack secret delivery path. A self-hosted instance creates its keys during enrollment and stores them with owner-only permissions in the instance's ignored secret -directory. The former `PAPERCLIP_ID_CONNECTOR_*` values use an incompatible +directory. The setup page supplies its authenticated same-origin HTTPS address +to enrollment, so a normal Tailscale-hosted self-hoster does not need to edit +`config.json` or set `PAPERCLIP_PUBLIC_URL`; the enrolled origin becomes the +durable callback binding. The former `PAPERCLIP_ID_CONNECTOR_*` values use an incompatible Paperclip ID protocol and are not read aliases. Enroll with Paperclip Cloud and reconnect legacy grants before their old access tokens expire. diff --git a/packages/shared/src/app-definitions.test.ts b/packages/shared/src/app-definitions.test.ts index 655c793851..e22d2e8f8d 100644 --- a/packages/shared/src/app-definitions.test.ts +++ b/packages/shared/src/app-definitions.test.ts @@ -203,7 +203,7 @@ describe("AppDefinition catalog",()=>{ oauthStrategy:"paperclip_cloud_connector", connectorProfile:expected.profile, capabilityProfile:{key:expected.capability}, - grantKinds:["user"], + grantKinds:["user","organization"], ownershipModes:["platform_shared"], defaults:{serverUrl:expected.serverUrl,scopesHint:expected.scopes}, riskTier:expected.riskTier, @@ -215,7 +215,7 @@ describe("AppDefinition catalog",()=>{ &&method.capabilityProfile?.key===expected.capability ); expect(customer,`${expected.profile}:customer-fallback`).toMatchObject({ - grantKinds:["user"], + grantKinds:["user","organization"], ownershipModes:["customer"], defaults:{serverUrl:expected.serverUrl,scopesHint:expected.scopes}, riskTier:expected.riskTier, diff --git a/packages/shared/src/app-definitions/gmail.json b/packages/shared/src/app-definitions/gmail.json index d04a6aeb16..a5d4b12cde 100644 --- a/packages/shared/src/app-definitions/gmail.json +++ b/packages/shared/src/app-definitions/gmail.json @@ -41,7 +41,8 @@ "description": "Search and read messages, threads, drafts, and labels." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "platform_shared" @@ -70,7 +71,8 @@ "description": "Search and read messages, threads, drafts, and labels." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "customer" @@ -112,7 +114,8 @@ "description": "Read Gmail and create drafts for review in Gmail." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "platform_shared" @@ -143,7 +146,8 @@ "description": "Read Gmail and create drafts for review in Gmail." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "customer" diff --git a/packages/shared/src/app-definitions/google-calendar.json b/packages/shared/src/app-definitions/google-calendar.json index ef74bb6ca8..b8b00444c1 100644 --- a/packages/shared/src/app-definitions/google-calendar.json +++ b/packages/shared/src/app-definitions/google-calendar.json @@ -41,7 +41,8 @@ "description": "Read calendars, events, and availability." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "platform_shared" @@ -72,7 +73,8 @@ "description": "Read calendars, events, and availability." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "customer" @@ -116,7 +118,8 @@ "description": "Create, update, respond to, and delete events." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "platform_shared" @@ -148,7 +151,8 @@ "description": "Create, update, respond to, and delete events." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "customer" diff --git a/packages/shared/src/app-definitions/google-chat.json b/packages/shared/src/app-definitions/google-chat.json index 4f6b4fc2b5..3f075f9cde 100644 --- a/packages/shared/src/app-definitions/google-chat.json +++ b/packages/shared/src/app-definitions/google-chat.json @@ -42,7 +42,8 @@ "description": "Search conversations and read messages." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "platform_shared" @@ -74,7 +75,8 @@ "description": "Search conversations and read messages." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "customer" @@ -120,7 +122,8 @@ "description": "Read Chat and send messages." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "platform_shared" @@ -154,7 +157,8 @@ "description": "Read Chat and send messages." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "customer" diff --git a/packages/shared/src/app-definitions/google-docs.json b/packages/shared/src/app-definitions/google-docs.json index 035038e71e..bb6eb24a18 100644 --- a/packages/shared/src/app-definitions/google-docs.json +++ b/packages/shared/src/app-definitions/google-docs.json @@ -42,7 +42,8 @@ "description": "Read document text and structure." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "platform_shared" @@ -72,7 +73,8 @@ "description": "Read document text and structure." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "customer" @@ -115,7 +117,8 @@ "description": "Read and update documents." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "platform_shared" @@ -147,7 +150,8 @@ "description": "Read and update documents." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "customer" diff --git a/packages/shared/src/app-definitions/google-drive.json b/packages/shared/src/app-definitions/google-drive.json index d47ebc7b12..6a944df553 100644 --- a/packages/shared/src/app-definitions/google-drive.json +++ b/packages/shared/src/app-definitions/google-drive.json @@ -42,7 +42,8 @@ "description": "Search and read files and metadata." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "platform_shared" @@ -71,7 +72,8 @@ "description": "Search and read files and metadata." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "customer" @@ -113,7 +115,8 @@ "description": "Read files and create or copy files." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "platform_shared" @@ -144,7 +147,8 @@ "description": "Read files and create or copy files." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "customer" diff --git a/packages/shared/src/app-definitions/google-people.json b/packages/shared/src/app-definitions/google-people.json index ffaa3384a9..07121347fb 100644 --- a/packages/shared/src/app-definitions/google-people.json +++ b/packages/shared/src/app-definitions/google-people.json @@ -41,7 +41,8 @@ "description": "Search contacts, directory people, and your profile." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "platform_shared" @@ -72,7 +73,8 @@ "description": "Search contacts, directory people, and your profile." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "customer" diff --git a/packages/shared/src/app-definitions/google-sheets.json b/packages/shared/src/app-definitions/google-sheets.json index fa63fe17a8..83e0ef0d31 100644 --- a/packages/shared/src/app-definitions/google-sheets.json +++ b/packages/shared/src/app-definitions/google-sheets.json @@ -43,7 +43,8 @@ "description": "Read spreadsheet values and structure." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "platform_shared" @@ -73,7 +74,8 @@ "description": "Read spreadsheet values and structure." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "customer" @@ -116,7 +118,8 @@ "description": "Read and update spreadsheet values, formulas, and dimensions." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "platform_shared" @@ -148,7 +151,8 @@ "description": "Read and update spreadsheet values, formulas, and dimensions." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "customer" diff --git a/packages/shared/src/app-definitions/google-slides.json b/packages/shared/src/app-definitions/google-slides.json index 1430e0e32e..ccde0900e2 100644 --- a/packages/shared/src/app-definitions/google-slides.json +++ b/packages/shared/src/app-definitions/google-slides.json @@ -42,7 +42,8 @@ "description": "Read presentation slides and content." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "platform_shared" @@ -72,7 +73,8 @@ "description": "Read presentation slides and content." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "customer" @@ -115,7 +117,8 @@ "description": "Read and update presentations." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "platform_shared" @@ -147,7 +150,8 @@ "description": "Read and update presentations." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "customer" diff --git a/packages/shared/src/app-definitions/google-workspace-search.json b/packages/shared/src/app-definitions/google-workspace-search.json index cfe2f1542d..f3ec9d00ed 100644 --- a/packages/shared/src/app-definitions/google-workspace-search.json +++ b/packages/shared/src/app-definitions/google-workspace-search.json @@ -41,7 +41,8 @@ "description": "Search Gmail, Drive, Calendar, and Chat without write access." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "platform_shared" @@ -74,7 +75,8 @@ "description": "Search Gmail, Drive, Calendar, and Chat without write access." }, "grantKinds": [ - "user" + "user", + "organization" ], "ownershipModes": [ "customer" diff --git a/packages/shared/src/feature-catalog.ts b/packages/shared/src/feature-catalog.ts index 68c0903afd..48b516eda9 100644 --- a/packages/shared/src/feature-catalog.ts +++ b/packages/shared/src/feature-catalog.ts @@ -82,12 +82,12 @@ export const INSTANCE_FEATURE_CATALOG: Record { expect(settings.enableBetaSkills).toBe(false); }); - it("defaults apps off", () => { + it("defaults the retired Apps compatibility key on", () => { const settings = instanceExperimentalSettingsSchema.parse({}); - expect(settings.enableApps).toBe(false); + expect(settings.enableApps).toBe(true); }); it("accepts worktree run execution patches", () => { diff --git a/packages/shared/src/validators/instance.ts b/packages/shared/src/validators/instance.ts index 2ad7fb5a16..6a392d3f0d 100644 --- a/packages/shared/src/validators/instance.ts +++ b/packages/shared/src/validators/instance.ts @@ -45,7 +45,10 @@ export const instanceExperimentalSettingsSchema = z.object({ enableManagedSandboxOnly: z.boolean().default(false), enableIsolatedWorkspaces: z.boolean().default(false), enableStreamlinedLeftNavigation: z.boolean().default(true), - enableApps: z.boolean().default(false), + // Deprecated compatibility key. Apps is a standard product surface and is + // always enabled; this remains accepted so older stored rows and managed + // configs continue to load during upgrades. + enableApps: z.boolean().default(true), enablePipelines: z.boolean().default(false), enableCases: z.boolean().default(false), enableConferenceRoomChat: z.boolean().default(false), diff --git a/server/src/__tests__/generic-mcp-connection.test.ts b/server/src/__tests__/generic-mcp-connection.test.ts index bb9859c4b1..6d496912d3 100644 --- a/server/src/__tests__/generic-mcp-connection.test.ts +++ b/server/src/__tests__/generic-mcp-connection.test.ts @@ -548,7 +548,8 @@ describeEmbeddedPostgres("generic remote MCP connections", () => { await expect(db.select().from(toolApplications)).resolves.toHaveLength(0); }); - it("emits a stable code when an application name is already used", async () => { + it("automatically gives a new connection a distinct name when its default is already used", async () => { + installMcpOAuthFixture({ auth: "public" }); const company = await createCompany(db); await db.insert(toolApplications).values({ companyId: company.id, @@ -565,12 +566,19 @@ describeEmbeddedPostgres("generic remote MCP connections", () => { const response = await request(app) .post(`/api/companies/${company.id}/tools/apps/connect`) - .send({ link: "http://127.0.0.1:8848/mcp", name: "Taken fixture name" }) - .expect(409); + .send({ link: MCP_URL, name: "Taken fixture name" }) + .expect(201); - expect(response.body).toMatchObject({ - details: { code: "tool_access_name_conflict" }, - }); + expect(response.body.application.name).toBe("Taken fixture name (2)"); + expect(response.body.connection.name).toBe("Taken fixture name (2)"); + await expect( + db.select({ name: toolApplications.name }) + .from(toolApplications) + .where(eq(toolApplications.companyId, company.id)), + ).resolves.toEqual(expect.arrayContaining([ + { name: "Taken fixture name" }, + { name: "Taken fixture name (2)" }, + ])); }); it("emits deployment guidance without exposing server env-var names", async () => { diff --git a/server/src/__tests__/instance-settings-managed-overlay.test.ts b/server/src/__tests__/instance-settings-managed-overlay.test.ts index 7fcfd61c38..7def7195c3 100644 --- a/server/src/__tests__/instance-settings-managed-overlay.test.ts +++ b/server/src/__tests__/instance-settings-managed-overlay.test.ts @@ -11,8 +11,8 @@ const MANAGED_RAW = JSON.stringify({ v: 1, mode: "cloud", catalogVersion: "2026.720.0", - // enableApps stored true in the DB gets forced off; enablePipelines has no - // stored value, so the overlay wins over the schema default (false). + // enableApps is retained only for compatibility and must be ignored; + // enablePipelines remains a live managed feature. features: { enableApps: false, enablePipelines: true }, plugins: { autoInstall: [] }, }); @@ -63,19 +63,17 @@ describe("applyManagedExperimentalOverlay", () => { expect(result.managedKeys).toEqual({}); }); - it("overlays managed values over stored values and records metadata", () => { + it("ignores the retired Apps flag while overlaying live managed values", () => { const config = parseManagedConfigEnv(managedEnv())!; const stored = normalizeExperimentalSettings({ enableApps: true }); const { experimental, managedKeys } = applyManagedExperimentalOverlay(stored, config); - // managed overlay > tenant DB value - expect(experimental.enableApps).toBe(false); + expect(experimental.enableApps).toBe(true); // managed overlay > schema default expect(experimental.enablePipelines).toBe(true); // unmanaged keys keep their stored/default values expect(experimental.enableCases).toBe(false); expect(managedKeys).toEqual({ - enableApps: { managed: true, managedBy: "paperclip-cloud" }, enablePipelines: { managed: true, managedBy: "paperclip-cloud" }, }); // input is not mutated @@ -96,10 +94,9 @@ describe("instanceSettingsService managed overlay", () => { const svc = instanceSettingsService(db, { runtimeEnv: managedEnv() }); const experimental = await svc.getExperimental(); - expect(experimental.enableApps).toBe(false); + expect(experimental.enableApps).toBe(true); expect(experimental.enablePipelines).toBe(true); expect(experimental.managedKeys).toEqual({ - enableApps: { managed: true, managedBy: "paperclip-cloud" }, enablePipelines: { managed: true, managedBy: "paperclip-cloud" }, }); }); @@ -109,11 +106,8 @@ describe("instanceSettingsService managed overlay", () => { const svc = instanceSettingsService(db, { runtimeEnv: managedEnv() }); const settings = await svc.get(); - expect(settings.experimental.enableApps).toBe(false); - expect(settings.experimental.managedKeys?.enableApps).toEqual({ - managed: true, - managedBy: "paperclip-cloud", - }); + expect(settings.experimental.enableApps).toBe(true); + expect(settings.experimental.managedKeys?.enableApps).toBeUndefined(); }); it("leaves the self-hosted read path unchanged (no managedKeys field)", async () => { @@ -137,8 +131,8 @@ describe("instanceSettingsService managed overlay", () => { expect(persistedSets).toHaveLength(1); const persisted = persistedSets[0]!.experimental as Record; - // The tenant's stored value survives in the DB even though the overlay - // masks it at read time — a later un-managing restores tenant intent. + // The retired compatibility key normalizes on, independent of the + // managed document's historical value. expect(persisted.enableApps).toBe(true); // The overlay-added value is not written. expect(persisted.enablePipelines).toBe(false); @@ -146,12 +140,9 @@ describe("instanceSettingsService managed overlay", () => { expect(persisted).not.toHaveProperty("managedKeys"); // The response still reflects the overlay. - expect(updated.experimental.enableApps).toBe(false); + expect(updated.experimental.enableApps).toBe(true); expect(updated.experimental.enablePipelines).toBe(true); - expect(updated.experimental.managedKeys?.enableApps).toEqual({ - managed: true, - managedBy: "paperclip-cloud", - }); + expect(updated.experimental.managedKeys?.enableApps).toBeUndefined(); }); it("does not let managed metadata leak into self-hosted writes", async () => { diff --git a/server/src/__tests__/instance-settings-service.test.ts b/server/src/__tests__/instance-settings-service.test.ts index 8d306d0c10..569fb6685b 100644 --- a/server/src/__tests__/instance-settings-service.test.ts +++ b/server/src/__tests__/instance-settings-service.test.ts @@ -29,7 +29,7 @@ describe("instance settings service", () => { enableManagedSandboxOnly: false, enableIsolatedWorkspaces: true, enableStreamlinedLeftNavigation: true, - enableApps: false, + enableApps: true, enableConferenceRoomChat: false, enableClassicTaskInterface: false, enableExternalObjects: false, @@ -59,10 +59,11 @@ describe("instance settings service", () => { }); }); - it("defaults enableApps to false for empty and legacy stored settings", () => { - expect(normalizeExperimentalSettings(undefined).enableApps).toBe(false); - expect(normalizeExperimentalSettings({}).enableApps).toBe(false); - expect(normalizeExperimentalSettings({ enablePipelines: true }).enableApps).toBe(false); + it("keeps Apps on for empty, legacy, and explicitly disabled stored settings", () => { + expect(normalizeExperimentalSettings(undefined).enableApps).toBe(true); + expect(normalizeExperimentalSettings({}).enableApps).toBe(true); + expect(normalizeExperimentalSettings({ enablePipelines: true }).enableApps).toBe(true); + expect(normalizeExperimentalSettings({ enableApps: false }).enableApps).toBe(true); }); it("retains the deprecated ingress key for stored-settings compatibility", () => { diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts index 809cdfb652..a329f14bf1 100644 --- a/server/src/__tests__/tool-access-service.test.ts +++ b/server/src/__tests__/tool-access-service.test.ts @@ -3670,6 +3670,8 @@ describeEmbeddedPostgres("tool access service", () => { expect(res.status).toBe(200); expect(res.body.capabilities).toEqual({ + canCreateOrganizationGrant: true, + organizationGrantReason: null, canSetCompanyInstall: true, companyInstallReason: null, }); @@ -3903,11 +3905,59 @@ describeEmbeddedPostgres("tool access service", () => { expect(res.status).toBe(200); expect(res.body.capabilities).toEqual({ + canCreateOrganizationGrant: false, + organizationGrantReason: "Only a company owner, administrator, or connection manager can share this credential with the organization.", canSetCompanyInstall: false, companyInstallReason: "Only someone who can configure this connection can choose this.", }); }); + it("requires connection-manager authority to create an organization credential", async () => { + const company = await createCompany(db); + const app = createRouteApp(db, boardSessionActor(company.id, "member")); + + await request(app) + .post(`/api/companies/${company.id}/tools/apps/connect`) + .send({ galleryKey: "notion", grantKind: "organization" }) + .expect(403); + // Omitted grantKind retains the legacy organization default, so it must + // pass through the same authorization check. + await request(app) + .post(`/api/companies/${company.id}/tools/apps/connect`) + .send({ galleryKey: "notion" }) + .expect(403); + + await expect(db.select().from(toolApplications)).resolves.toHaveLength(0); + await expect(db.select().from(toolConnections)).resolves.toHaveLength(0); + }); + + it("requires connection-manager authority to resume an organization credential", async () => { + const company = await createCompany(db); + const service = createTestToolAccessService(db); + const shared = await service.connectGalleryApp(company.id, { + galleryKey: "notion", + grantKind: "organization", + }, { actorType: "user", actorId: "member" }); + const app = createRouteApp(db, boardSessionActor(company.id, "member", "member")); + + // The retained shared identity wins over a contradictory personal value. + await request(app) + .post(`/api/companies/${company.id}/tools/apps/connect`) + .send({ + galleryKey: "notion", + resumeConnectionId: shared.connectionId, + grantKind: "user", + }) + .expect(403); + + // Omitting the explicit id must not bypass the same check through the + // service's retained draft lookup by application name and source. + await request(app) + .post(`/api/companies/${company.id}/tools/apps/connect`) + .send({ galleryKey: "notion", grantKind: "user" }) + .expect(403); + }); + it("previews remote mcp.json headers as secret replacement fields without echoing values", async () => { const company = await createCompany(db); const app = createRouteApp(db); @@ -5127,6 +5177,101 @@ describeEmbeddedPostgres("tool access service", () => { } }); + it("routes a managed Drive callback into the shared organization vault when selected", async () => { + const company = await createCompany(db); + const userId = `shared-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 } }]); + + try { + const connected = await service.connectGalleryApp(company.id, { + galleryKey: "google-drive", + connectionMethodKey: "paperclip-read", + grantKind: "organization", + name: "Shared Drive managed read", + }, actor); + expect(connected.connection).toMatchObject({ credentialPolicy: "shared" }); + + const started = await service.startOAuth(company.id, connected.connectionId, { + redirectUri: "https://paperclip.example/api/tools/oauth/cloud-connector/callback", + actor, + }); + const completed = await service.completePaperclipCloudConnectorCallback({ + state: new URL(started.authorizationUrl).searchParams.get("state")!, + claimId: "shared-drive-claim", + actor, + }); + + expect(completed.connection).toMatchObject({ + status: "active", + enabled: true, + credentialPolicy: "shared", + config: { + oauth: { + strategy: "paperclip_cloud_connector", + connectorSubjectUserId: userId, + }, + }, + }); + expect(completed.connection.credentialRefs).toEqual([ + expect.objectContaining({ + name: "oauth.access_token", + placement: "header", + key: "Authorization", + prefix: "Bearer ", + }), + ]); + expect(completed.connection.credentialSecretRefs.map((ref) => ref.configPath).sort()).toEqual([ + "oauth.access_token", + "oauth.refresh_token", + ]); + + const grants = await db.select().from(connectionGrants).where(eq( + connectionGrants.connectionId, + connected.connectionId, + )); + expect(grants).toHaveLength(1); + expect(grants[0]).toMatchObject({ + kind: "organization", + subjectUserId: null, + isDefault: true, + status: "active", + providerTenant: { + externalId: userId, + oauth: { strategy: "paperclip_cloud_connector" }, + }, + }); + expect(grants[0]!.credentialSecretRefs.map((ref) => ref.secretId).sort()).toEqual( + completed.connection.credentialSecretRefs.map((ref) => ref.secretId).sort(), + ); + + const secrets = await db.select().from(companySecrets).where(inArray( + companySecrets.id, + completed.connection.credentialSecretRefs.map((ref) => ref.secretId), + )); + expect(secrets).toHaveLength(2); + expect(secrets.every((secret) => secret.scope === "company" && secret.ownerUserId === null)).toBe(true); + const bindings = await db.select().from(companySecretBindings).where(and( + eq(companySecretBindings.targetType, "tool_connection"), + eq(companySecretBindings.targetId, connected.connectionId), + )); + expect(bindings.map((binding) => binding.configPath).sort()).toEqual([ + "credentials.oauth.access_token", + "oauth.access_token", + "oauth.refresh_token", + ]); + } finally { + driveDefinition.ownershipAvailability = previousOwnershipAvailability; + } + }); + it("activates allowed Drive write actions with recommended approval defaults after a managed callback", async () => { const company = await createCompany(db); const userId = `drive-write-member-${randomUUID()}`; @@ -5671,7 +5816,7 @@ describeEmbeddedPostgres("tool access service", () => { vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret"); const company = await createCompany(db); const userId = `oauth-owner-${randomUUID()}`; - await grantBoardUser(db, company.id, userId, ["tools:use"]); + await grantBoardUser(db, company.id, userId, ["tools:use", "tools:manage_connections"]); const agent = await createAgent(db, company.id); const service = createTestToolAccessService(db); const connected = await service.connectGalleryApp(company.id, { @@ -5780,7 +5925,7 @@ describeEmbeddedPostgres("tool access service", () => { vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id"); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret"); const company = await createCompany(db); - await grantBoardUser(db, company.id, "workspace-owner", []); + await grantBoardUser(db, company.id, "workspace-owner", ["tools:manage_connections"]); const agent = await createAgent(db, company.id); const { issue, run } = await createIssueAndRun(db, company.id, agent.id); const service = createTestToolAccessService(db); @@ -6439,7 +6584,7 @@ describeEmbeddedPostgres("tool access service", () => { vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret"); vi.stubEnv("PAPERCLIP_PUBLIC_URL", "https://paperclip-public.example"); const company = await createCompany(db); - await grantBoardUser(db, company.id, "board-user", []); + await grantBoardUser(db, company.id, "board-user", ["tools:manage_connections"]); const app = createRouteApp(db); const connectRes = await request(app) @@ -6555,6 +6700,7 @@ describeEmbeddedPostgres("tool access service", () => { }); it("normalizes a direct numeric loopback origin for OAuth when no public URL is configured", async () => { + vi.stubEnv("PAPERCLIP_PUBLIC_URL", ""); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id"); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret"); const company = await createCompany(db); @@ -6573,6 +6719,7 @@ describeEmbeddedPostgres("tool access service", () => { }); it("does not derive an OAuth callback origin from a non-loopback request host", async () => { + vi.stubEnv("PAPERCLIP_PUBLIC_URL", ""); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id"); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret"); const company = await createCompany(db); @@ -6591,6 +6738,48 @@ describeEmbeddedPostgres("tool access service", () => { }); }); + it("uses an authenticated same-origin HTTPS browser request without public URL config", async () => { + vi.stubEnv("PAPERCLIP_PUBLIC_URL", ""); + vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id"); + vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret"); + const company = await createCompany(db); + const app = createRouteApp(db, boardSessionActor(company.id, "owner"), undefined, { + deploymentMode: "authenticated", + deploymentExposure: "private", + }); + + const connectRes = await request(app) + .post(`/api/companies/${company.id}/tools/apps/connect`) + .set("Host", "paperclip.tail123.ts.net") + .set("Origin", "https://paperclip.tail123.ts.net") + .send({ galleryKey: "slack", name: "Tailscale Slack workspace" }); + + expect(connectRes.status).toBe(201); + expect(new URL(connectRes.body.auth.startUrl).searchParams.get("redirect_uri")).toBe( + "https://paperclip.tail123.ts.net/api/tools/oauth/callback", + ); + }); + + it("rejects a browser HTTPS origin that does not match the routed request host", async () => { + vi.stubEnv("PAPERCLIP_PUBLIC_URL", ""); + vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id"); + vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret"); + const company = await createCompany(db); + const app = createRouteApp(db, boardSessionActor(company.id, "owner"), undefined, { + deploymentMode: "authenticated", + deploymentExposure: "private", + }); + + const connectRes = await request(app) + .post(`/api/companies/${company.id}/tools/apps/connect`) + .set("Host", "paperclip.tail123.ts.net") + .set("Origin", "https://evil.example") + .send({ galleryKey: "slack", name: "Mismatched Slack workspace" }); + + expect(connectRes.status).toBe(422); + expect(connectRes.body).toMatchObject({ code: "oauth_redirect_origin_unsupported" }); + }); + it("requires non-viewer board access to start OAuth for active app connections", async () => { vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id"); vi.stubEnv("PAPERCLIP_PUBLIC_URL", "http://paperclip.test"); @@ -6729,7 +6918,7 @@ describeEmbeddedPostgres("tool access service", () => { vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret"); vi.stubEnv("PAPERCLIP_PUBLIC_URL", "http://paperclip.test"); const company = await createCompany(db); - await grantBoardUser(db, company.id, "oauth-operator", []); + await grantBoardUser(db, company.id, "oauth-operator", ["tools:manage_connections"]); const service = createTestToolAccessService(db); const initiatingActor = boardSessionActor(company.id, "operator", "oauth-operator"); const connect = await service.connectGalleryApp( @@ -6776,6 +6965,25 @@ describeEmbeddedPostgres("tool access service", () => { .query({ state, code: "oauth-code" }) .expect(403); + await db.delete(principalPermissionGrants).where(and( + eq(principalPermissionGrants.companyId, company.id), + eq(principalPermissionGrants.principalType, "user"), + eq(principalPermissionGrants.principalId, "oauth-operator"), + eq(principalPermissionGrants.permissionKey, "tools:manage_connections"), + )); + await request(initiatingApp) + .get("/api/tools/oauth/callback") + .query({ state, code: "oauth-code" }) + .expect(403); + await db.insert(principalPermissionGrants).values({ + companyId: company.id, + principalType: "user", + principalId: "oauth-operator", + permissionKey: "tools:manage_connections", + scope: null, + grantedByUserId: "owner", + }); + await expect(db.select().from(toolOauthStates)).resolves.toHaveLength(1); vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { @@ -6954,7 +7162,7 @@ describeEmbeddedPostgres("tool access service", () => { vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_ID", ""); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_SECRET", ""); const company = await createCompany(db); - await grantBoardUser(db, company.id, "board", []); + await grantBoardUser(db, company.id, "board", ["tools:manage_connections"]); const service = createTestToolAccessService(db); const connected = await service.connectGalleryApp(company.id, { galleryKey: "supabase", @@ -7080,7 +7288,7 @@ describeEmbeddedPostgres("tool access service", () => { vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_ID", ""); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_CLIENT_SECRET", ""); const company = await createCompany(db); - await grantBoardUser(db, company.id, "board", []); + await grantBoardUser(db, company.id, "board", ["tools:manage_connections"]); const service = createTestToolAccessService(db); const connected = await service.connectGalleryApp(company.id, { galleryKey: "miro", @@ -7587,7 +7795,7 @@ describeEmbeddedPostgres("tool access service", () => { vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id"); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret"); const company = await createCompany(db); - await grantBoardUser(db, company.id, "board", []); + await grantBoardUser(db, company.id, "board", ["tools:manage_connections"]); const service = createTestToolAccessService(db); const concurrentService = createTestToolAccessService(db); @@ -7693,7 +7901,7 @@ describeEmbeddedPostgres("tool access service", () => { vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id"); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret"); const company = await createCompany(db); - await grantBoardUser(db, company.id, "board", []); + await grantBoardUser(db, company.id, "board", ["tools:manage_connections"]); const service = createTestToolAccessService(db); const connect = await service.connectGalleryApp(company.id, { galleryKey: "slack", name: "Slack invalid grant" }); const start = await service.startOAuth(company.id, connect.connectionId, { @@ -7784,7 +7992,7 @@ describeEmbeddedPostgres("tool access service", () => { vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id"); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret"); const company = await createCompany(db); - await grantBoardUser(db, company.id, "board", []); + await grantBoardUser(db, company.id, "board", ["tools:manage_connections"]); const service = createTestToolAccessService(db); const connect = await service.connectGalleryApp(company.id, { galleryKey: "slack", @@ -7980,7 +8188,7 @@ describeEmbeddedPostgres("tool access service", () => { vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_ID", "slack-client-id"); vi.stubEnv("PAPERCLIP_TOOL_OAUTH_SLACK_CLIENT_SECRET", "slack-client-secret"); const company = await createCompany(db); - await grantBoardUser(db, company.id, "board", []); + await grantBoardUser(db, company.id, "board", ["tools:manage_connections"]); const service = createTestToolAccessService(db); const connect = await service.connectGalleryApp(company.id, { galleryKey: "slack", name: "Slack no refresh" }); const start = await service.startOAuth(company.id, connect.connectionId, { @@ -8696,7 +8904,7 @@ describeEmbeddedPostgres("tool access service", () => { await expect(db.select().from(toolConnections)).resolves.toHaveLength(1); }); - it("allows multiple same-named connections on one application", async () => { + it("automatically gives same-named connections distinct names", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); mockToolsList([ @@ -8722,10 +8930,41 @@ describeEmbeddedPostgres("tool access service", () => { expect(second.connectionId).not.toBe(first.connectionId); const rows = await db.select().from(toolConnections).where(eq(toolConnections.applicationId, first.application.id)); expect(rows).toHaveLength(2); - expect(rows.map((row) => row.name)).toEqual(["Notion", "Notion"]); + expect(rows.map((row) => row.name).sort()).toEqual(["Notion", "Notion (2)"]); expect(new Set(rows.map((row) => row.uid))).toHaveProperty("size", 2); }); + it("automatically resolves same-name application races", async () => { + const company = await createCompany(db); + const service = createTestToolAccessService(db); + mockToolsList([ + { + name: "read_items", + description: "Read items.", + inputSchema: { type: "object", properties: {} }, + annotations: { readOnlyHint: true }, + }, + ]); + + const results = await Promise.all([ + service.connectGalleryApp(company.id, { + link: "https://parallel-one.example.test/actions", + name: "Parallel app", + }, { actorType: "user", actorId: "board" }), + service.connectGalleryApp(company.id, { + link: "https://parallel-two.example.test/actions", + name: "Parallel app", + }, { actorType: "user", actorId: "board" }), + ]); + + expect(new Set(results.map((result) => result.application.id))).toHaveProperty("size", 2); + const applications = await db + .select({ name: toolApplications.name }) + .from(toolApplications) + .where(eq(toolApplications.companyId, company.id)); + expect(applications.map((row) => row.name).sort()).toEqual(["Parallel app", "Parallel app (2)"]); + }); + it("does not delete a reused application when the connect rolls back", async () => { const company = await createCompany(db); const service = createTestToolAccessService(db); @@ -8874,7 +9113,7 @@ describeEmbeddedPostgres("tool access service", () => { vi.stubEnv("PAPERCLIP_TOOL_OAUTH_GENERIC_EXAMPLE_TEST_CLIENT_SECRET", "generic-client-secret"); vi.stubEnv("PAPERCLIP_PUBLIC_URL", "http://paperclip.test"); const company = await createCompany(db); - await grantBoardUser(db, company.id, "board-user", []); + await grantBoardUser(db, company.id, "board-user", ["tools:manage_connections"]); const app = createRouteApp(db); const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, init) => { const href = String(url); @@ -9057,7 +9296,7 @@ describeEmbeddedPostgres("tool access service", () => { it("starts OAuth only for the marked Smoke Lab HTTP fixture", async () => { const company = await createCompany(db); - await grantBoardUser(db, company.id, "board", []); + await grantBoardUser(db, company.id, "board", ["tools:manage_connections"]); const service = createTestToolAccessService(db); const [application] = await db.insert(toolApplications).values({ companyId: company.id, diff --git a/server/src/middleware/board-mutation-guard.ts b/server/src/middleware/board-mutation-guard.ts index a6347b869e..e0f2cd606c 100644 --- a/server/src/middleware/board-mutation-guard.ts +++ b/server/src/middleware/board-mutation-guard.ts @@ -33,15 +33,21 @@ function trustedOriginsForRequest(req: Request) { return origins; } -function isTrustedBoardMutationRequest(req: Request) { +/** + * Return the browser origin only when it is the same origin Paperclip's CSRF + * guard accepts for this request. Callers may use this as browser-reachability + * evidence, but must still apply any protocol-specific constraints (for + * example OAuth requiring HTTPS outside loopback). + */ +export function trustedBoardMutationOrigin(req: Request): string | null { const allowedOrigins = trustedOriginsForRequest(req); const origin = parseOrigin(req.header("origin")); - if (origin && allowedOrigins.has(origin)) return true; + if (origin && allowedOrigins.has(origin)) return origin; const refererOrigin = parseOrigin(req.header("referer")); - if (refererOrigin && allowedOrigins.has(refererOrigin)) return true; + if (refererOrigin && allowedOrigins.has(refererOrigin)) return refererOrigin; - return false; + return null; } export function boardMutationGuard(): RequestHandler { @@ -68,7 +74,7 @@ export function boardMutationGuard(): RequestHandler { return; } - if (!isTrustedBoardMutationRequest(req)) { + if (!trustedBoardMutationOrigin(req)) { res.status(403).json({ error: "Board mutation requires trusted browser origin" }); return; } diff --git a/server/src/routes/tool-access-connection-intent.test.ts b/server/src/routes/tool-access-connection-intent.test.ts index ff5f76b6a5..9f438528f9 100644 --- a/server/src/routes/tool-access-connection-intent.test.ts +++ b/server/src/routes/tool-access-connection-intent.test.ts @@ -16,6 +16,24 @@ describe("Cloud connector enrollment return path", () => { "/QA%20%2F%20Apps/apps/connections?cloud_connector=enrolled", ); }); + + it("returns to the connector setup that started enrollment", () => { + expect(cloudConnectorEnrollmentReturnPath( + "APP", + "/apps/connect?source=google-drive&stage=setup", + )).toBe( + "/APP/apps/connect?source=google-drive&stage=setup&cloud_connector=enrolled", + ); + }); + + it("rejects external and unrelated enrollment return paths", () => { + expect(cloudConnectorEnrollmentReturnPath("APP", "https://evil.example/apps/connect")).toBe( + "/APP/apps/connections?cloud_connector=enrolled", + ); + expect(cloudConnectorEnrollmentReturnPath("APP", "/settings")).toBe( + "/APP/apps/connections?cloud_connector=enrolled", + ); + }); }); describe("connection intent OAuth callback document", () => { diff --git a/server/src/routes/tool-access.ts b/server/src/routes/tool-access.ts index 22dc5d0a5f..b76638af13 100644 --- a/server/src/routes/tool-access.ts +++ b/server/src/routes/tool-access.ts @@ -71,6 +71,7 @@ import { oauthClientIdMetadataDocument, } from "../services/tool-access.js"; import { isLoopbackHost } from "../url-utils.js"; +import { trustedBoardMutationOrigin } from "../middleware/board-mutation-guard.js"; import { connectionIntentService } from "../services/connection-intents.js"; import { redactRemoteUrlCredential } from "../services/remote-url-credentials.js"; import { wakeConnectionIntentAfterResolution } from "./connection-intents.js"; @@ -78,6 +79,8 @@ import type { heartbeatService } from "../services/heartbeat.js"; const COMPANY_INSTALL_DENIAL_REASON = "Only someone who can configure this connection can choose this."; +const ORGANIZATION_GRANT_DENIAL_REASON = + "Only a company owner, administrator, or connection manager can share this credential with the organization."; type Heartbeat = ReturnType; /** Allowlist (e.g. Google Sheets allowed spreadsheet ids) lives in connection config. */ @@ -180,8 +183,31 @@ export function connectionIntentOAuthOutcomeHtml(input: { return `Connection authorization

Returning to Paperclip…

`; } -export function cloudConnectorEnrollmentReturnPath(issuePrefix: string): string { - return `/${encodeURIComponent(issuePrefix)}/apps/connections?cloud_connector=enrolled`; +function normalizeCloudConnectorEnrollmentReturnTo(returnTo?: string | null): string | null { + if (!returnTo || returnTo.length > 2_048) return null; + try { + const parsed = new URL(returnTo, "http://paperclip.local"); + if ( + parsed.origin !== "http://paperclip.local" + || parsed.pathname !== "/apps/connect" + || parsed.username + || parsed.password + ) return null; + return `${parsed.pathname}${parsed.search}`; + } catch { + return null; + } +} + +export function cloudConnectorEnrollmentReturnPath(issuePrefix: string, returnTo?: string | null): string { + const companyRoot = `/${encodeURIComponent(issuePrefix)}`; + const normalizedReturnTo = normalizeCloudConnectorEnrollmentReturnTo(returnTo); + if (normalizedReturnTo) { + const parsed = new URL(normalizedReturnTo, "http://paperclip.local"); + parsed.searchParams.set("cloud_connector", "enrolled"); + return `${companyRoot}${parsed.pathname}${parsed.search}`; + } + return `${companyRoot}/apps/connections?cloud_connector=enrolled`; } export function toolAccessRoutes( @@ -319,8 +345,62 @@ export function toolAccessRoutes( } } + function trustedBrowserBaseUrl(req: Request) { + const origin = trustedBoardMutationOrigin(req); + if (!origin) return null; + try { + const parsed = new URL(origin); + const forwardedHost = req.header("x-forwarded-host")?.split(",")[0]?.trim(); + const routedHost = forwardedHost || req.header("host")?.trim(); + const normalizedRoutedHost = routedHost + ? new URL(`${parsed.protocol}//${routedHost}`).host.toLowerCase() + : null; + if ( + parsed.host.toLowerCase() === normalizedRoutedHost + && (parsed.protocol === "https:" || (parsed.protocol === "http:" && isLoopbackHost(parsed.hostname))) + ) { + return parsed.origin; + } + } catch { + // The shared origin parser already validates this. Fail closed if its + // contract ever changes. + } + return null; + } + + function enrolledConnectorBaseUrl(req: Request) { + // Browser-initiated mutations must prove their own same-origin HTTPS + // request. The durable binding is only a callback/metadata fallback for + // provider GETs, which do not carry the initiating browser's Origin. + if (req.method !== "GET" && req.method !== "HEAD") return null; + const identity = loadPaperclipCloudConnectorIdentity(); + if (identity?.status !== "active") return null; + const forwardedHost = req.header("x-forwarded-host")?.split(",")[0]?.trim(); + const requestHost = (forwardedHost || req.header("host")?.trim())?.toLowerCase(); + if (!requestHost) return null; + for (const origin of identity.origins) { + try { + const parsed = new URL(origin); + if ( + parsed.protocol === "https:" + && !parsed.username + && !parsed.password + && parsed.host.toLowerCase() === requestHost + ) { + return parsed.origin; + } + } catch { + // Ignore malformed legacy identity origins. + } + } + return null; + } + function oauthRedirectUri(req: Request) { - const baseUrl = configuredPublicBaseUrl() ?? requestLoopbackBaseUrl(req); + const baseUrl = configuredPublicBaseUrl() + ?? trustedBrowserBaseUrl(req) + ?? enrolledConnectorBaseUrl(req) + ?? requestLoopbackBaseUrl(req); if (!baseUrl) { throw unprocessable( "This Paperclip needs a browser-reachable HTTPS address (or loopback HTTP) before browser sign-in can start.", @@ -331,6 +411,8 @@ export function toolAccessRoutes( } function oauthBrowserOrigin(req: Request) { + const trustedBrowserOrigin = trustedBrowserBaseUrl(req); + if (trustedBrowserOrigin) return trustedBrowserOrigin; const host = req.get("host")?.trim(); if (!host) return null; try { @@ -513,10 +595,12 @@ function connectorEnrollmentPrincipal(req: Request): string { req: Request, companyId: string, ): Promise { - const canSetCompanyInstall = await isToolConnectionManagerQuiet(req, companyId); + const canManageConnections = await isToolConnectionManagerQuiet(req, companyId); return { - canSetCompanyInstall, - companyInstallReason: canSetCompanyInstall ? null : COMPANY_INSTALL_DENIAL_REASON, + canCreateOrganizationGrant: canManageConnections, + organizationGrantReason: canManageConnections ? null : ORGANIZATION_GRANT_DENIAL_REASON, + canSetCompanyInstall: canManageConnections, + companyInstallReason: canManageConnections ? null : COMPANY_INSTALL_DENIAL_REASON, }; } @@ -749,6 +833,23 @@ function connectorEnrollmentPrincipal(req: Request): string { router.post("/companies/:companyId/tools/apps/connect", validate(connectToolAppSchema), async (req, res) => { const companyId = req.params.companyId as string; assertToolAppMutationAccess(req, companyId); + // An omitted grant kind is the backward-compatible organization default. + // On resume, the persisted connection identity is authoritative: accepting + // a contradictory `grantKind: "user"` here could otherwise let a creator + // replace the credential behind an existing organization grant. + const resumedConnection = req.body.resumeConnectionId + ? await svc.getConnection(req.body.resumeConnectionId, companyId) + : null; + const effectiveGrantKind = resumedConnection + ? resumedConnection.credentialPolicy === "per_user" ? "user" : "organization" + : req.body.grantKind ?? "organization"; + // Personal connection creation remains available to ordinary active + // members, but sharing a credential with every human is a manager + // operation and must be enforced here, not inferred by the client. + const createsOrganizationGrant = effectiveGrantKind === "organization"; + if (createsOrganizationGrant && !await isToolConnectionManagerQuiet(req, companyId)) { + throw forbidden(ORGANIZATION_GRANT_DENIAL_REASON); + } try { const result = await svc.connectGalleryApp(companyId, req.body, getActorInfo(req)); if (result.auth?.kind === "oauth") { @@ -863,6 +964,9 @@ function connectorEnrollmentPrincipal(req: Request): string { if (!companyId) throw badRequest("Paperclip Cloud enrollment requires a company"); assertCompanyAccess(req, companyId); const origin = new URL(oauthRedirectUri(req)).origin; + const returnTo = normalizeCloudConnectorEnrollmentReturnTo( + typeof req.body?.returnTo === "string" ? req.body.returnTo : undefined, + ) ?? undefined; let status; try { status = await startPaperclipCloudConnectorEnrollment({ @@ -870,6 +974,7 @@ function connectorEnrollmentPrincipal(req: Request): string { companyId, initiatedBy: connectorEnrollmentPrincipal(req), label: typeof req.body?.label === "string" ? req.body.label : undefined, + returnTo, }); } catch { throw unprocessable("Paperclip Cloud enrollment could not be started", { @@ -926,7 +1031,7 @@ function connectorEnrollmentPrincipal(req: Request): string { details: { environment: status.environment, status: status.status }, }); } - res.redirect(303, cloudConnectorEnrollmentReturnPath(company.issuePrefix)); + res.redirect(303, cloudConnectorEnrollmentReturnPath(company.issuePrefix, pending?.returnTo)); }); const handlePaperclipCloudConnectorCallback = async (req: Request, res: Response) => { @@ -1145,7 +1250,11 @@ function connectorEnrollmentPrincipal(req: Request): string { } const pendingConnection = await svc.getConnection(pendingState.connectionId, pendingState.companyId); const pendingConnectionIntent = await isConnectionIntent(pendingState.interactionId); - if (pendingState.subjectUserId && pendingState.subjectUserId === req.actor.userId) { + if (!pendingState.subjectUserId) { + if (!await isToolConnectionManagerQuiet(req, pendingConnection.companyId)) { + throw forbidden(ORGANIZATION_GRANT_DENIAL_REASON); + } + } else if (pendingState.subjectUserId === req.actor.userId) { await assertToolConnectionAccess(req, pendingConnection); } else { await assertToolConnectionConfigureAccess(req, pendingConnection); diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index feb04ef33c..d590dde57e 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -224,7 +224,9 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableManagedSandboxOnly: parsed.data.enableManagedSandboxOnly ?? false, enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false, enableStreamlinedLeftNavigation: parsed.data.enableStreamlinedLeftNavigation ?? true, - enableApps: parsed.data.enableApps ?? false, + // Apps graduated from Experimental. Ignore historical off values while + // continuing to accept the compatibility key in stored settings. + enableApps: true, enablePipelines: parsed.data.enablePipelines ?? false, enableCases: parsed.data.enableCases ?? false, enableConferenceRoomChat: parsed.data.enableConferenceRoomChat ?? false, @@ -260,7 +262,7 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableManagedSandboxOnly: false, enableIsolatedWorkspaces: false, enableStreamlinedLeftNavigation: true, - enableApps: false, + enableApps: true, enablePipelines: false, enableCases: false, enableConferenceRoomChat: false, @@ -314,6 +316,9 @@ export function applyManagedExperimentalOverlay( for (const [key, value] of Object.entries(managedConfig.features) as Array< [ManagedExperimentalFeatureKey, boolean] >) { + // Existing Cloud stack configs may still carry enableApps. Accept the + // document during rollout, but never let the retired flag disable Apps. + if (key === "enableApps") continue; next[key] = value; managedKeys[key] = { managed: true, managedBy: PAPERCLIP_CLOUD_MANAGED_BY }; } diff --git a/server/src/services/paperclip-cloud-connector-enrollment.ts b/server/src/services/paperclip-cloud-connector-enrollment.ts index c5a2d0db74..103c34aedf 100644 --- a/server/src/services/paperclip-cloud-connector-enrollment.ts +++ b/server/src/services/paperclip-cloud-connector-enrollment.ts @@ -25,6 +25,7 @@ type PendingEnrollment = { expiresAt: string; companyId?: string; initiatedBy?: string; + returnTo?: string; }; export type PaperclipCloudConnectorIdentity = { @@ -132,6 +133,7 @@ export async function startPaperclipCloudConnectorEnrollment(input: { label?: string; companyId?: string; initiatedBy?: string; + returnTo?: string; env?: NodeJS.ProcessEnv; request?: typeof fetch; }): Promise { @@ -143,6 +145,7 @@ async function startPaperclipCloudConnectorEnrollmentUnlocked(input: { label?: string; companyId?: string; initiatedBy?: string; + returnTo?: string; env?: NodeJS.ProcessEnv; request?: typeof fetch; }): Promise { @@ -217,6 +220,7 @@ async function startPaperclipCloudConnectorEnrollmentUnlocked(input: { expiresAt: body.expiresAt, ...(input.companyId ? { companyId: input.companyId } : {}), ...(input.initiatedBy ? { initiatedBy: input.initiatedBy } : {}), + ...(input.returnTo ? { returnTo: input.returnTo } : {}), }, }; saveIdentity(identity); diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index a21eee694a..89340a5d8b 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -13,6 +13,7 @@ import { companyMemberships, companySecretBindings, companySecrets, + principalPermissionGrants, userSecretDefinitions, heartbeatRuns, issues, @@ -137,6 +138,7 @@ import { type OAuthEndpointUrlRejection, } from "@paperclipai/shared"; import { badRequest, conflict, forbidden, HttpError, notFound, unprocessable } from "../errors.js"; +import { isUniqueViolation } from "../db-errors.js"; import { logger } from "../middleware/logger.js"; import { logActivity } from "./activity-log.js"; import { @@ -189,6 +191,7 @@ type ActorInfo = { actorType?: "agent" | "user" | "system" | "plugin"; actorId?: string | null; sessionId?: string | null; + actorSource?: "local_implicit" | "session" | "board_key" | "agent_key" | "agent_jwt" | "cloud_tenant"; }; const ACTIVE_BROKER_RUN_STATUSES = new Set(["running"]); @@ -8299,6 +8302,19 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} return base.length <= 160 ? base : base.slice(0, 160); } + function nextAvailableConnectionName(requestedName: string, existingNames: readonly string[]): string { + const base = requestedName.trim() || "Custom app"; + const used = new Set(existingNames.map((candidate) => candidate.trim().toLocaleLowerCase())); + const unsuffixed = base.slice(0, 160); + if (!used.has(unsuffixed.toLocaleLowerCase())) return unsuffixed; + for (let index = 2; index < 10_000; index += 1) { + const suffix = ` (${index})`; + const candidate = `${base.slice(0, 160 - suffix.length).trimEnd()}${suffix}`; + if (!used.has(candidate.toLocaleLowerCase())) return candidate; + } + return `${base.slice(0, 151).trimEnd()} (${randomUUID().slice(0, 6)})`; + } + async function connectGalleryApp( companyId: string, input: ConnectToolApp, @@ -8369,7 +8385,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} } } - const name = input.name ?? existingApplication?.name ?? galleryEntry?.name ?? defaultLinkName(input.link ?? ""); + const requestedName = input.name ?? existingApplication?.name ?? galleryEntry?.name ?? defaultLinkName(input.link ?? ""); // Compatibility for the original Sheets robot flow, whose clients predate // method selection and identify the method by its spreadsheet allowlist. const inferredMethodKey = !input.connectionMethodKey @@ -8429,11 +8445,64 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} .limit(1) : [undefined]; const retainedConnection = requestedResumeConnection ?? recoveredConnection; + let applicationName = existingApplication?.name ?? requestedName; + let name = retainedConnection?.name ?? requestedName; + if (!existingApplication) { + const applicationNames = await db + .select({ name: toolApplications.name }) + .from(toolApplications) + .where(eq(toolApplications.companyId, companyId)); + applicationName = nextAvailableConnectionName(requestedName, applicationNames.map((row) => row.name)); + name = applicationName; + } else if (!retainedConnection) { + const connectionNames = await db + .select({ name: toolConnections.name }) + .from(toolConnections) + .where(and( + eq(toolConnections.companyId, companyId), + eq(toolConnections.applicationId, existingApplication.id), + )); + name = nextAvailableConnectionName(requestedName, connectionNames.map((row) => row.name)); + } const retainedGrantKind: ConnectionGrantKind | null = retainedConnection ? retainedConnection.credentialPolicy === "per_user" ? "user" : "organization" : null; + // The route can authorize an explicit resume before entering the service, + // but name/source recovery happens here. Do not let a caller submit a + // personal grant choice to pass the route and then inherit an implicitly + // recovered organization identity. Local implicit mode is already the + // unrestricted instance operator; every authenticated user must still hold + // current connection-manager authority before this retained row is touched. + if ( + retainedGrantKind === "organization" + && input.grantKind === "user" + && actor?.actorType === "user" + && actor.actorSource !== "local_implicit" + ) { + const actorUserId = actor.actorId; + const [membership] = actorUserId ? await db.select({ + membershipRole: companyMemberships.membershipRole, + }).from(companyMemberships).where(and( + eq(companyMemberships.companyId, companyId), + eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, actorUserId), + eq(companyMemberships.status, "active"), + )).limit(1) : []; + const roleCanManage = membership?.membershipRole === "owner" || membership?.membershipRole === "admin"; + const [explicitManagerGrant] = roleCanManage || !actorUserId ? [] : await db.select({ + id: principalPermissionGrants.id, + }).from(principalPermissionGrants).where(and( + eq(principalPermissionGrants.companyId, companyId), + eq(principalPermissionGrants.principalType, "user"), + eq(principalPermissionGrants.principalId, actorUserId), + eq(principalPermissionGrants.permissionKey, "tools:manage_connections"), + )).limit(1); + if (!roleCanManage && !explicitManagerGrant) { + throw forbidden("Only a company owner, admin, or member with connection-manager permission can share credentials with the organization."); + } + } const requestedGrantKind = retainedGrantKind ?? input.grantKind ?? "organization"; if (method?.grantKinds && !method.grantKinds.includes(requestedGrantKind)) { throw badRequest(`${galleryEntry?.name ?? "This app"} supports only ${method.grantKinds.join(" or ")} credentials`); @@ -8770,15 +8839,40 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} applicationRow = existingApplication; } } else { - [applicationRow] = await db.insert(toolApplications).values({ - companyId, - applicationKey: `app-gallery:${galleryEntry?.slug ?? "link"}:${randomUUID()}`, - name, - description: safeApplicationDescription, - type: transport === "mcp_remote" ? "mcp_http" : "mcp_stdio", - status: "draft", - metadata: galleryEntry ? { sourceTemplateKey: galleryEntry.slug, galleryKey: galleryEntry.slug } : { source: "link" }, - }).returning(); + // Name selection is optimistic because multiple setup requests can + // legitimately begin at the same time. The company/name unique index + // is the authority: if another request wins after our read, refresh the + // names and retry with the next suffix instead of surfacing a conflict + // the user never asked to resolve. + for (let attempt = 0; attempt < 10 && !applicationRow; attempt += 1) { + try { + [applicationRow] = await db.insert(toolApplications).values({ + companyId, + applicationKey: `app-gallery:${galleryEntry?.slug ?? "link"}:${randomUUID()}`, + name: applicationName, + description: safeApplicationDescription, + type: transport === "mcp_remote" ? "mcp_http" : "mcp_stdio", + status: "draft", + metadata: galleryEntry ? { sourceTemplateKey: galleryEntry.slug, galleryKey: galleryEntry.slug } : { source: "link" }, + }).returning(); + } catch (error) { + if (!isUniqueViolation(error, "tool_applications_company_name_uq")) throw error; + const applicationNames = await db + .select({ name: toolApplications.name }) + .from(toolApplications) + .where(eq(toolApplications.companyId, companyId)); + applicationName = nextAvailableConnectionName( + requestedName, + applicationNames.map((row) => row.name), + ); + name = applicationName; + } + } + if (!applicationRow) { + throw conflict("Paperclip could not allocate a unique connection name", { + code: "tool_access_name_allocation_exhausted", + }); + } } await assertSecretRefs(companyId, [...credentialRefs, ...credentialSecretRefs]); @@ -10181,13 +10275,17 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} if (!consumedState) throw badRequest("OAuth state was not found, expired, or has already been used"); const txSecrets = secretService(tx); const txSecretContext = { dbClient: tx, secretClient: txSecrets }; - const [existingUserGrant] = await tx.select().from(connectionGrants).where(and( + const personalCredential = connection.credentialPolicy === "per_user"; + const [existingCredentialGrant] = await tx.select().from(connectionGrants).where(and( eq(connectionGrants.companyId, connection.companyId), eq(connectionGrants.connectionId, connection.id), - eq(connectionGrants.kind, "user"), - eq(connectionGrants.subjectUserId, subjectUserId), + eq(connectionGrants.kind, personalCredential ? "user" : "organization"), + personalCredential + ? eq(connectionGrants.subjectUserId, subjectUserId) + : eq(connectionGrants.isDefault, true), )).limit(1); - const existingRefs = existingUserGrant?.credentialSecretRefs ?? []; + const existingRefs = existingCredentialGrant?.credentialSecretRefs + ?? (personalCredential ? [] : connection.credentialSecretRefs); const accessRef = await createOrRotateOAuthSecret({ companyId: connection.companyId, connection, @@ -10196,7 +10294,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} value: credentials.accessToken, actor: input.actor, existingRefs, - ownerUserId: subjectUserId, + ownerUserId: personalCredential ? subjectUserId : undefined, }, txSecretContext); const refreshRef = await createOrRotateOAuthSecret({ companyId: connection.companyId, @@ -10206,7 +10304,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} value: refreshToken, actor: input.actor, existingRefs, - ownerUserId: subjectUserId, + ownerUserId: personalCredential ? subjectUserId : undefined, }, txSecretContext); const credentialSecretRefs = [ ...existingRefs.filter((ref) => ref.configPath !== "oauth.access_token" && ref.configPath !== "oauth.refresh_token"), @@ -10231,16 +10329,16 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} revokedByUserId: null, updatedAt: now(), }; - if (existingUserGrant) { - await tx.update(connectionGrants).set(grantValues).where(eq(connectionGrants.id, existingUserGrant.id)); + if (existingCredentialGrant) { + await tx.update(connectionGrants).set(grantValues).where(eq(connectionGrants.id, existingCredentialGrant.id)); } else { await tx.insert(connectionGrants).values({ companyId: connection.companyId, connectionId: connection.id, - kind: "user", - subjectUserId, + kind: personalCredential ? "user" : "organization", + subjectUserId: personalCredential ? subjectUserId : null, ...grantValues, - isDefault: false, + isDefault: !personalCredential, createdByUserId: subjectUserId, }); } @@ -10262,13 +10360,31 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} authKind: "oauth", config: nextConfig, transportConfig: nextConfig, + credentialRefs: personalCredential + ? connection.credentialRefs.filter((ref) => ref.name !== "oauth.access_token") + : [ + ...connection.credentialRefs.filter((ref) => ref.name !== "oauth.access_token"), + { + name: "oauth.access_token", + secretId: accessRef.secretId, + version: "latest" as const, + placement: "header" as const, + key: "Authorization", + prefix: "Bearer ", + }, + ], + credentialSecretRefs: personalCredential + ? connection.credentialSecretRefs.filter( + (ref) => ref.configPath !== "oauth.access_token" && ref.configPath !== "oauth.refresh_token", + ) + : credentialSecretRefs, updatedAt: now(), }).where(eq(toolConnections.id, connection.id)).returning(); await tx.update(toolApplications).set({ status: shouldFinalizeManagedDefaults ? "draft" : "active", updatedAt: now(), }).where(eq(toolApplications.id, connection.applicationId)); - await syncCredentialBindings(connection, credentialSecretRefs, tx); + await syncCredentialBindings(connection, personalCredential ? credentialSecretRefs : [], tx); const linkedInteractionKind = stateRow.interactionId ? await tx .select({ kind: issueThreadInteractions.kind }) @@ -10737,7 +10853,10 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} // Keep callback persistence serialized with membership suspension, role // downgrade, and removal. Once this row is locked, authority cannot be // revoked between the live check and the shared credential/grant writes. - const [membership] = await tx.select({ id: companyMemberships.id }).from(companyMemberships).where(and( + const [membership] = await tx.select({ + id: companyMemberships.id, + membershipRole: companyMemberships.membershipRole, + }).from(companyMemberships).where(and( eq(companyMemberships.companyId, connection.companyId), eq(companyMemberships.principalType, "user"), eq(companyMemberships.principalId, organizationActorUserId), @@ -10747,6 +10866,18 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} if (!membership) { throw forbidden("Your company membership no longer permits connection changes. Ask a company owner to restore non-viewer access before you authorize this connection again."); } + const roleCanManage = membership.membershipRole === "owner" || membership.membershipRole === "admin"; + const [explicitManagerGrant] = roleCanManage ? [] : await tx.select({ + id: principalPermissionGrants.id, + }).from(principalPermissionGrants).where(and( + eq(principalPermissionGrants.companyId, connection.companyId), + eq(principalPermissionGrants.principalType, "user"), + eq(principalPermissionGrants.principalId, organizationActorUserId), + eq(principalPermissionGrants.permissionKey, "tools:manage_connections"), + )).limit(1).for("update"); + if (!roleCanManage && !explicitManagerGrant) { + throw forbidden("Only a company owner, admin, or member with connection-manager permission can share credentials with the organization."); + } const txSecrets = secretService(tx); const txSecretContext = { dbClient: tx, secretClient: txSecrets }; diff --git a/tests/e2e/app-not-connected.spec.ts b/tests/e2e/app-not-connected.spec.ts index 4b448eab64..71a93dbf8d 100644 --- a/tests/e2e/app-not-connected.spec.ts +++ b/tests/e2e/app-not-connected.spec.ts @@ -15,8 +15,6 @@ async function newCompany(request: APIRequestContext, label: string): Promise { await page.getByRole("button", { name: "Reconnect", exact: true }).click(); await expect(page).toHaveURL(/\/apps\/connect\?/, { timeout: 20_000 }); await expect(page.getByText("Connect your own MCP server")).toBeVisible({ timeout: 20_000 }); + await page.getByRole("button", { name: "Save and continue" }).click(); await expect(page.getByText(mock.url)).toBeVisible(); await page.screenshot({ path: `${SCREENSHOT_DIR}/apps-nav-w6-02-reconnect-prefilled.png`, fullPage: true }); diff --git a/tests/e2e/application-delete-screenshot.spec.ts b/tests/e2e/application-delete-screenshot.spec.ts index cfb15c7acb..960373877a 100644 --- a/tests/e2e/application-delete-screenshot.spec.ts +++ b/tests/e2e/application-delete-screenshot.spec.ts @@ -4,9 +4,6 @@ import { expect, test } from "@playwright/test"; // table now redirects into Apps, so capture the current app removal // confirmation on the app Advanced tab instead. test("captures the current app removal confirmations", async ({ page }) => { - const flags = await page.request.patch("/api/instance/settings/experimental", { data: { enableApps: true } }); - expect(flags.ok(), `enable apps failed ${flags.status()}: ${await flags.text()}`).toBe(true); - const companyRes = await page.request.post("/api/companies", { data: { name: `PAP-10817 remove app ${Date.now()}` }, }); diff --git a/tests/e2e/applications-crud.spec.ts b/tests/e2e/applications-crud.spec.ts index c08fdad975..9e9a64f6af 100644 --- a/tests/e2e/applications-crud.spec.ts +++ b/tests/e2e/applications-crud.spec.ts @@ -18,8 +18,6 @@ async function discoverCompany(request: APIRequestContext): Promise }); expect(res.ok(), `create company failed ${res.status()}: ${await res.text()}`).toBe(true); const company = await res.json(); - const flags = await request.patch("/api/instance/settings/experimental", { data: { enableApps: true } }); - expect(flags.ok(), `enable apps failed ${flags.status()}: ${await flags.text()}`).toBe(true); return { companyId: company.id, prefix: company.issuePrefix ?? company.prefix ?? company.urlKey ?? "E2E", diff --git a/tests/e2e/apps-dark-mode-shots.spec.ts b/tests/e2e/apps-dark-mode-shots.spec.ts index c98d055592..f1a5282569 100644 --- a/tests/e2e/apps-dark-mode-shots.spec.ts +++ b/tests/e2e/apps-dark-mode-shots.spec.ts @@ -15,8 +15,6 @@ async function newCompany(request: APIRequestContext, label: string): Promise { await linkInput.fill(mock.url); await page.getByRole("button", { name: "Continue" }).click(); + // Access is chosen before credentials so the user knows who and which + // agents will receive the connection before Paperclip contacts it. + await expect(page.getByText("Which humans can use this credential?")).toBeVisible(); + await page.getByRole("button", { name: "Save and continue" }).click(); + // LinkKey step keeps the BYO connection heading. Mock doesn't // require a key — leave the default "No" answer. await expect(page.getByRole("heading", { name: "Connect your own MCP server" })).toBeVisible({ timeout: 15_000 }); diff --git a/tests/e2e/connection-intents.spec.ts b/tests/e2e/connection-intents.spec.ts index b45e23a515..c2d180e9c9 100644 --- a/tests/e2e/connection-intents.spec.ts +++ b/tests/e2e/connection-intents.spec.ts @@ -22,11 +22,6 @@ async function newCompany(request: APIRequestContext): Promise { data: { name: `Connection intent E2E ${Date.now()}` }, }), ); - await json( - await request.patch("/api/instance/settings/experimental", { - data: { enableApps: true }, - }), - ); return { companyId: company.id, prefix: company.issuePrefix }; } @@ -221,6 +216,7 @@ test("store setup and task connection intent share one fake provider through con .getByPlaceholder("https://example.com/actions") .fill(provider.url); await page.getByRole("button", { name: "Continue" }).click(); + await page.getByRole("button", { name: "Save and continue" }).click(); await page.getByRole("button", { name: /Check link/i }).click(); // A no-auth read-only provider can complete the access/install defaults in // one commit. Other methods exercise the same intermediate steps in the diff --git a/tests/e2e/mcp-user-stories.spec.ts b/tests/e2e/mcp-user-stories.spec.ts index 2a42dffd8c..60e6ee565d 100644 --- a/tests/e2e/mcp-user-stories.spec.ts +++ b/tests/e2e/mcp-user-stories.spec.ts @@ -24,7 +24,6 @@ async function newCompany(request: APIRequestContext, label: string): Promise( await request.post("/api/companies", { data: { name: `MCP US ${label} ${Date.now()}` } }), ); - await json(await request.patch("/api/instance/settings/experimental", { data: { enableApps: true } })); return { companyId: body.id, prefix: body.issuePrefix ?? body.prefix ?? body.urlKey ?? "E2E" }; } diff --git a/tests/e2e/smoke-lab.shared.ts b/tests/e2e/smoke-lab.shared.ts index 1cb8eaf75b..4e91bbee60 100644 --- a/tests/e2e/smoke-lab.shared.ts +++ b/tests/e2e/smoke-lab.shared.ts @@ -82,7 +82,7 @@ async function createScout(request: APIRequestContext, companyId: string): Promi } async function enableSmokeLab(request: APIRequestContext) { - await json(await request.patch("/api/instance/settings/experimental", { data: { enableSmokeLab: true, enableApps: true } })); + await json(await request.patch("/api/instance/settings/experimental", { data: { enableSmokeLab: true } })); } async function createSmokeRun(request: APIRequestContext, companyId: string, scenarioCount: number) { diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 23b9bd9b2d..803188021c 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -9,7 +9,6 @@ import { TaskChatLab } from "./pages/TaskChatLab"; import { PipelinesExperimentalGate } from "./components/PipelinesExperimentalGate"; import { CasesExperimentalGate } from "./components/CasesExperimentalGate"; import { StatusCardsExperimentalGate } from "./components/StatusCardsExperimentalGate"; -import { AppsExperimentalGate } from "./components/AppsExperimentalGate"; import { CloudManagedPageGate } from "./components/CloudManagedPageGate"; import { HiddenSettingsPageGate } from "./components/HiddenSettingsPageGate"; import { IsolatedWorkspacesRouteGate } from "./components/IsolatedWorkspacesRouteGate"; @@ -156,37 +155,35 @@ function boardRoutes() { } /> } /> } /> - }> - } /> - } /> - } /> - } /> - } - /> - } /> - } /> - } /> - } /> - {/* Connector health is inline on the Apps landing page; keep legacy links working. */} - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - + } /> + } /> + } /> + } /> + } + /> + } /> + } /> + } /> + } /> + {/* Connector health is inline on the Apps landing page; keep legacy links working. */} + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> }> } /> diff --git a/ui/src/api/tools.ts b/ui/src/api/tools.ts index 78a252c377..fb66a3326f 100644 --- a/ui/src/api/tools.ts +++ b/ui/src/api/tools.ts @@ -289,8 +289,8 @@ export type ToolPolicyTestResponse = { export const toolsApi = { getCloudConnectorEnrollment: () => api.get("/tools/oauth/cloud-connector/enrollment"), - startCloudConnectorEnrollment: (companyId: string, label?: string) => - api.post("/tools/oauth/cloud-connector/enrollment", { companyId, label }), + startCloudConnectorEnrollment: (companyId: string, label?: string, returnTo?: string) => + api.post("/tools/oauth/cloud-connector/enrollment", { companyId, label, returnTo }), // --- Applications --- listGallery: (companyId: string) => api.get(`/companies/${companyId}/tools/gallery`), diff --git a/ui/src/components/AppsExperimentalGate.test.tsx b/ui/src/components/AppsExperimentalGate.test.tsx deleted file mode 100644 index 5a386cac89..0000000000 --- a/ui/src/components/AppsExperimentalGate.test.tsx +++ /dev/null @@ -1,84 +0,0 @@ -// @vitest-environment jsdom - -import { flushSync } from "react-dom"; -import { createRoot, type Root } from "react-dom/client"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { AppsExperimentalGate } from "./AppsExperimentalGate"; - -const mockInstanceSettingsApi = vi.hoisted(() => ({ - getExperimental: vi.fn(), -})); - -vi.mock("@/api/instanceSettings", () => ({ - instanceSettingsApi: mockInstanceSettingsApi, -})); - -vi.mock("@/lib/router", () => ({ - Navigate: ({ to, replace }: { to: string; replace?: boolean }) => ( -
- ), - Outlet: () =>
Apps content
, -})); - -async function flushReact() { - for (let index = 0; index < 5; index += 1) { - await Promise.resolve(); - await new Promise((resolve) => window.setTimeout(resolve, 0)); - } - flushSync(() => {}); -} - -describe("AppsExperimentalGate", () => { - let container: HTMLDivElement; - let root: Root | null = null; - - async function renderGate() { - root = createRoot(container); - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - flushSync(() => { - root!.render( - - - , - ); - }); - await flushReact(); - } - - beforeEach(() => { - container = document.createElement("div"); - document.body.appendChild(container); - }); - - afterEach(() => { - flushSync(() => root?.unmount()); - root = null; - container.remove(); - vi.clearAllMocks(); - }); - - it("redirects to the dashboard when apps are disabled", async () => { - mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableApps: false }); - await renderGate(); - - expect(container.querySelector('[data-testid="navigate"]')?.getAttribute("data-to")).toBe("/dashboard"); - expect(container.querySelector('[data-testid="apps-content"]')).toBeNull(); - }); - - it("renders apps routes when apps are enabled", async () => { - mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableApps: true }); - await renderGate(); - - expect(container.querySelector('[data-testid="apps-content"]')).not.toBeNull(); - expect(container.querySelector('[data-testid="navigate"]')).toBeNull(); - }); - - it("renders nothing while the flag is loading", async () => { - mockInstanceSettingsApi.getExperimental.mockImplementation(() => new Promise(() => {})); - await renderGate(); - - expect(container.querySelector('[data-testid="navigate"]')).toBeNull(); - expect(container.querySelector('[data-testid="apps-content"]')).toBeNull(); - }); -}); diff --git a/ui/src/components/AppsExperimentalGate.tsx b/ui/src/components/AppsExperimentalGate.tsx deleted file mode 100644 index 3bbe1eda83..0000000000 --- a/ui/src/components/AppsExperimentalGate.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { Navigate, Outlet } from "@/lib/router"; -import { useAppsEnabled } from "@/hooks/useAppsEnabled"; - -export function AppsExperimentalGate() { - const { enabled, loaded } = useAppsEnabled(); - - if (!loaded) return null; - if (!enabled) return ; - return ; -} diff --git a/ui/src/components/Layout.test.tsx b/ui/src/components/Layout.test.tsx index 6dbda8fe0c..5596289aea 100644 --- a/ui/src/components/Layout.test.tsx +++ b/ui/src/components/Layout.test.tsx @@ -565,7 +565,7 @@ describe("Layout", () => { }); }); - it("does not mount the Apps secondary sidebar while experimental apps are disabled", async () => { + it("mounts the Apps secondary sidebar regardless of the retired experimental flag", async () => { currentPathname = "/PAP/apps"; mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableApps: false }); const root = createRoot(container); @@ -583,9 +583,9 @@ describe("Layout", () => { await flushReact(); await flushReact(); - expect(container.textContent).not.toContain("Apps sidebar"); + expect(container.textContent).toContain("Apps sidebar"); expect(container.textContent).toContain("Main company nav"); - expect(mockSetForceCollapsed).toHaveBeenCalledWith(false); + expect(mockSetForceCollapsed).toHaveBeenCalledWith(true); await act(async () => { root.unmount(); diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index 4b5528121d..898f715955 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -29,7 +29,6 @@ import { usePanel } from "../context/PanelContext"; import { useCompany } from "../context/CompanyContext"; import { useSidebar } from "../context/SidebarContext"; import { useKeyboardShortcuts } from "../hooks/useKeyboardShortcuts"; -import { useAppsEnabled } from "../hooks/useAppsEnabled"; import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory"; import { healthApi } from "../api/health"; import { instanceSettingsApi } from "../api/instanceSettings"; @@ -113,7 +112,6 @@ export function Layout() { const navigate = useNavigate(); const location = useLocation(); const navigationType = useNavigationType(); - const { enabled: appsEnabled } = useAppsEnabled(); const isCompanySettingsRoute = [ "/company/settings", "/company/export", @@ -176,11 +174,11 @@ export function Layout() { // both desktop (SecondarySidebar) and mobile (off-canvas drawer). const secondarySidebar = isCompanySettingsRoute ? ( - ) : appsEnabled && appDetailConnectionId ? ( + ) : appDetailConnectionId ? ( - ) : appsEnabled && appDetailApplicationId ? ( + ) : appDetailApplicationId ? ( - ) : appsEnabled && (isAppsRoute || isToolsRoute) ? ( + ) : isAppsRoute || isToolsRoute ? ( ) : routeSidebarSlot ? ( { }); }); - it("hides the Apps nav item unless experimental apps are enabled", async () => { + it("always shows Apps at the bottom of the Work section", async () => { mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableApps: false }); - const disabledRoot = await renderSidebar(); + const root = await renderSidebar(); - expect([...container.querySelectorAll("a")].some((anchor) => anchor.textContent === "Apps")).toBe(false); - - flushSync(() => { - disabledRoot.unmount(); - }); - - mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableApps: true }); - const enabledRoot = await renderSidebar(); - - const link = [...container.querySelectorAll("a")].find((anchor) => anchor.textContent === "Apps"); + const links = [...container.querySelectorAll("a")]; + const link = links.find((anchor) => anchor.textContent === "Apps"); expect(link?.getAttribute("href")).toBe("/apps"); + expect(links.findIndex((anchor) => anchor.textContent === "Apps")).toBeGreaterThan( + links.findIndex((anchor) => anchor.textContent === "Projects"), + ); + expect(links.findIndex((anchor) => anchor.textContent === "Apps")).toBeLessThan( + links.findIndex((anchor) => anchor.textContent === "Org"), + ); flushSync(() => { - enabledRoot.unmount(); + root.unmount(); }); }); diff --git a/ui/src/components/Sidebar.tsx b/ui/src/components/Sidebar.tsx index f54b3dec79..2281375062 100644 --- a/ui/src/components/Sidebar.tsx +++ b/ui/src/components/Sidebar.tsx @@ -83,7 +83,6 @@ export function Sidebar() { usePublishSharedQueryData(sharedLiveRuns, liveRuns, liveRunsUpdatedAt); const liveRunCount = liveRuns?.length ?? 0; const showWorkspacesLink = experimentalSettings?.enableIsolatedWorkspaces === true; - const showApps = experimentalSettings?.enableApps === true; const showPipelines = experimentalSettings?.enablePipelines === true; const showStatusCards = experimentalSettings?.enableStatusCards === true; const goalsLinkPending = experimentalSettings === undefined; @@ -266,6 +265,7 @@ export function Sidebar() { className="flex flex-col gap-0.5" itemClassName="text-(length:--text-compact) font-medium" /> + {/* Classic mode restores the per-project collapsible below Work. */} @@ -275,7 +275,6 @@ export function Sidebar() { - {showApps ? : null} {/* One entry — /audit merged into the rich Activity feed (PAP-16302). */} diff --git a/ui/src/features/connections/ConnectionSetupFlow.tsx b/ui/src/features/connections/ConnectionSetupFlow.tsx index 876f2737f6..1f94c056d0 100644 --- a/ui/src/features/connections/ConnectionSetupFlow.tsx +++ b/ui/src/features/connections/ConnectionSetupFlow.tsx @@ -7,6 +7,7 @@ import { Check, ChevronDown, ChevronRight, + Cloud, Copy, Link2, Loader2, @@ -209,10 +210,10 @@ const STEP_INDEX: Record, number> = { key: 2, }; const ZAPIER_STEP_INDEX: Record, number> = { - key: 0, - access: 1, + access: 0, + key: 1, }; -const ZAPIER_STEP_LABELS = ["Add MCP URL"]; +const ZAPIER_STEP_LABELS = ["Access", "Add MCP URL"]; /** * Which identity a fresh connection should default to (PAP-17835). @@ -437,7 +438,9 @@ export function ConnectionSetupFlow({ }; }); - const [step, setStep] = useState(requestedAppKey || prefill.link || zapierSource ? "key" : "gallery"); + const [step, setStep] = useState( + requestedAppKey ? "key" : prefill.link || zapierSource ? "access" : "gallery", + ); const [entry, setEntry] = useState(null); const [galleryName, setGalleryName] = useState(""); const [linkUrl, setLinkUrl] = useState(prefill.link); @@ -577,7 +580,7 @@ export function ConnectionSetupFlow({ resetGenericAuthState(); setCredentials({}); setConnectResult(null); - setStep("key"); + setStep("access"); navigate(withConnectionIntent("/apps/connect?source=zapier", connectionIntentId)); return; } @@ -629,18 +632,75 @@ export function ConnectionSetupFlow({ useEffect(() => { if (host !== "page") return; setBreadcrumbs([ - { label: selectedCompany?.name ?? "Company", href: "/dashboard" }, - { label: "Apps", href: "/apps" }, + { label: "Connectors", href: "/apps" }, { label: vercelConnectMode ? "Vercel Connect" : byoOnly ? "Connect your own tool" : "Connect an app" }, ]); return () => setBreadcrumbs([]); - }, [byoOnly, host, setBreadcrumbs, selectedCompany?.name, vercelConnectMode]); + }, [byoOnly, host, setBreadcrumbs, vercelConnectMode]); const galleryQuery = useQuery({ queryKey: queryKeys.apps.gallery(selectedCompanyId ?? "__none__"), queryFn: () => toolsApi.listGallery(selectedCompanyId!), enabled: !!selectedCompanyId, }); + const fullRequestedDefinition = requestedAppKey + ? getConnectableAppDefinition(requestedAppKey) + : null; + const requestedDefinitionUsesManagedConnector = Boolean( + fullRequestedDefinition?.methods.some((candidate) => + candidate.oauthStrategy === "paperclip_cloud_connector" + || candidate.oauthStrategy === "paperclip_id_connector" + ), + ); + const entryAdvertisesManagedConnector = Boolean( + entry?.methods.some((candidate) => + candidate.oauthStrategy === "paperclip_cloud_connector" + || candidate.oauthStrategy === "paperclip_id_connector" + ), + ); + const connectorEnrollmentQuery = useQuery({ + queryKey: ["cloud-connector", "enrollment"], + queryFn: () => toolsApi.getCloudConnectorEnrollment(), + enabled: Boolean( + selectedCompanyId + && requestedDefinitionUsesManagedConnector + && !entryAdvertisesManagedConnector + ), + }); + const [connectorEnrollmentError, setConnectorEnrollmentError] = useState(null); + const openConnectorEnrollment = useCallback((verificationUrl: string) => { + const target = resolveAuthorizationTarget(verificationUrl); + if (!target.ok) { + setConnectorEnrollmentError(target.message); + return; + } + navigateTopLevel(target.url); + }, []); + const startConnectorEnrollment = useMutation({ + mutationFn: () => toolsApi.startCloudConnectorEnrollment( + selectedCompanyId!, + selectedCompany?.name, + requestedAppKey + ? appConnectHref(requestedAppKey, "key", credentialSource, { + resumeConnectionId, + reconnectConnectionId, + interactionId: connectionIntentId, + }) + : undefined, + ), + onSuccess: (status) => { + if (!status.verificationUrl) { + setConnectorEnrollmentError("Paperclip Cloud did not return an enrollment link. Try again."); + return; + } + openConnectorEnrollment(status.verificationUrl); + }, + onError: (error) => { + setConnectorEnrollmentError( + error instanceof Error ? error.message : "Paperclip couldn’t reach Paperclip Cloud. Try again.", + ); + }, + }); const applicationsQuery = useQuery({ queryKey: queryKeys.tools.applications(selectedCompanyId ?? "__none__"), queryFn: () => toolsApi.listApplications(selectedCompanyId!), @@ -1414,6 +1474,18 @@ export function ConnectionSetupFlow({ && (directOAuthEntry || oauthPhase !== "entry"), ); + const showConnectorEnrollmentStep = Boolean( + step === "key" + && entry + && requestedDefinitionUsesManagedConnector + && !entryAdvertisesManagedConnector + && ( + connectorEnrollmentQuery.isLoading + || connectorEnrollmentQuery.isError + || connectorEnrollmentQuery.data?.configured !== true + ) + ); + if (showCuratedOAuthState && automaticOAuthEntry) { return ( )} - {step === "key" && entry && ( + {step === "key" && entry && showConnectorEnrollmentStep ? ( +
+
+
+ +
+
+

+ Connect with Paperclip +

+
+
+ + {connectorEnrollmentQuery.isError || connectorEnrollmentError ? ( + + {connectorEnrollmentError ?? "Paperclip couldn’t check Cloud registration. Try again."} + + ) : null} + +
+ + +
+
+ ) : step === "key" && entry ? ( - )} + ) : null} {step === "key" && !entry && linkUrl && !zapierSource && ( { setLinkNeedsKey(next); @@ -1746,7 +1855,7 @@ export function ConnectionSetupFlow({ matchedEntry={linkMatchedEntry} onUseMatchedEntry={linkMatchedEntry ? () => useMatchedGalleryEntry(linkMatchedEntry) : undefined} submitting={connectMutation.isPending || genericOAuthPending} - onBack={backToGallery} + onBack={() => setStep("access")} onConnect={() => { setLinkGuidance(null); connectMutation.mutate(undefined); @@ -1759,7 +1868,7 @@ export function ConnectionSetupFlow({ link={linkUrl} onLinkChange={setLinkUrl} submitting={connectMutation.isPending} - onBack={backToGallery} + onBack={() => setStep("access")} onConnect={() => connectMutation.mutate(undefined)} /> )} @@ -1848,7 +1957,7 @@ function StepHeader({ ) : null}

- {appIdentity ? `Connect ${appIdentity.name}` : "Connect an app"} + {appIdentity ? `Connect ${appIdentity.name}` : "Connect your own MCP server"}

{subtitle}

{unverifiedHost ? : null} @@ -1943,7 +2052,7 @@ export function OAuthConnectStateScreen({ unverifiedHost={unverifiedHost} onCancel={onCancel} /> -
+
{phase === "error" ? ( @@ -1975,10 +2084,6 @@ export function OAuthConnectStateScreen({ )}
-

- - Your authorization stays in Paperclip’s encrypted secret store. -

); @@ -2002,20 +2107,8 @@ function ZapierConnectStep({ const isZapierLink = zapierHostname === "zapier.com" || zapierHostname.endsWith(".zapier.com"); return ( -
-
- - - -
-

Connect Zapier

-

- Paste the complete MCP URL Zapier gives you, including its token. -

-
-
- -
+
+
-

- The token is part of the URL. Paperclip stores it securely and checks the connection before enabling actions. -

{link.trim() && !isZapierLink && (

Paste a valid Zapier URL to continue.

)}
-
+
@@ -2327,7 +2417,6 @@ function normalizeAppLink(value: string): string | null { function LinkConnectStep({ link, name, - onNameChange, needsKey, onNeedsKeyChange, keyValue, @@ -2351,7 +2440,6 @@ function LinkConnectStep({ }: { link: string; name: string; - onNameChange: (next: string) => void; needsKey: boolean; onNeedsKeyChange: (next: boolean) => void; keyValue: string; @@ -2388,28 +2476,17 @@ function LinkConnectStep({ }; const canSubmit = canSubmitGenericConnect(draft); const showSimpleKeyQuestion = authMode === "auto"; - const nameInputRef = useRef(null); const displayedLink = matchedEntry?.slug === "zapier" ? redactUrlSecrets(link) : link; - useEffect(() => { - if (guidance?.focus === "name") nameInputRef.current?.focus(); - }, [guidance]); - const updateHeader = (id: string, patch: Partial) => { onHeadersChange(headers.map((row) => (row.id === id ? { ...row, ...patch } : row))); }; return ( -
-
- - - -
-

Connect your own MCP server

-

{displayedLink}

- -
+
+
+

{displayedLink}

+
{matchedEntry && onUseMatchedEntry ? ( @@ -2432,22 +2509,7 @@ function LinkConnectStep({
) : null} -
-
- - onNameChange(e.target.value)} - placeholder="My app" - className="mt-2 h-11" - /> -

- We filled this in from the address. Change it if you'd like. -

-
- +
{showSimpleKeyQuestion && (
@@ -2485,7 +2547,6 @@ function LinkConnectStep({ className="mt-2 h-11 font-mono" />
-
) : null} @@ -2553,7 +2614,6 @@ function LinkConnectStep({ Add another header {headerError ?

{headerError}

: null} -
) : null} @@ -2590,7 +2650,6 @@ function LinkConnectStep({ className="mt-2 h-11 font-mono" />
-
) : null} @@ -2601,15 +2660,10 @@ function LinkConnectStep({ -
- - We'll check the server before turning anything on. - - -
+
); @@ -2649,21 +2703,6 @@ const GENERIC_AUTH_MODE_OPTIONS: Array<{ mode: GenericMcpAuthMode; label: string }, ]; -function StoredSecurelyNote() { - return ( -
- -
-
Stored securely.
-
- Paperclip keeps this in its encrypted secret store. You can replace it anytime from this app's page, - but it can't be read back. -
-
-
- ); -} - function SegmentedOption({ label, selected, @@ -2690,44 +2729,8 @@ function SegmentedOption({ ); } -function ConnectionNameField({ - name, - onNameChange, -}: { - name: string; - onNameChange: (next: string) => void; -}) { - return ( -
- - onNameChange(e.target.value)} - placeholder="My app" - className="mt-2 h-11" - /> -

- We filled this in from the app. Change it to tell connections apart. -

-
- ); -} - -function connectionMethodAuthenticationLabel(method: ConnectionMethodDef): string { - if (method.auth === "api_key") return "Authentication: API key"; - if (method.auth === "none") return "Authentication: no credentials"; - if (connectionMethodSupportsAutomaticOAuth(method)) { - return connectionMethodAcceptsCustomerOAuthClient(method) - ? "Authentication: browser sign-in or your OAuth app" - : "Authentication: browser sign-in"; - } - return "Authentication: your OAuth app"; -} - function KeyStep({ entry, - name, - onNameChange, values, onChange, oauthClientId, @@ -2750,8 +2753,6 @@ function KeyStep({ onConnect, }: { entry: AppDefinition; - name: string; - onNameChange: (next: string) => void; values: Record; onChange: (next: Record) => void; oauthClientId: string; @@ -2777,7 +2778,6 @@ function KeyStep({ onBack: () => void; onConnect: () => void; }) { - const copy = appCopyFor(entry.slug, entry.description); const methods = useMemo( () => connectionMethodsForCredentialSource(entry, credentialSource), [credentialSource, entry], @@ -2908,7 +2908,6 @@ function KeyStep({ options={capabilityMethods.map((candidate) => ({ value: candidate.key, title: candidate.label ?? (candidate.auth === "oauth" ? `Sign in with ${entry.name}` : "Use an API key"), - description: `${connectionMethodAuthenticationLabel(candidate)}. ${candidate.whenToUse}`, }))} /> {!method &&

Choose a connection method to continue.

} @@ -2919,18 +2918,9 @@ function KeyStep({ const parsed = parseGoogleSheetIds(googleSheetsLinks); const canConnect = !unavailable && Boolean(robotEmail) && googleSheetsLinks.trim().length > 0; return ( -
-
- -
-

Connect Google Sheets

-

{copy.short}

-
-
- -
+
+
{capabilitySelection} - {robotEmail ? (
@@ -2992,43 +2982,28 @@ function KeyStep({ ); } - return ( -
-
- -
-

Connect {entry.name}

-

{copy.short}

-
-
+ const requirementsUrl = method?.consoleLinks?.docs ?? entry.docsUrl; -
+ return ( +
+ {requirementsUrl ? ( + + ) : null} + +
{capabilitySelection} {authenticationSelection} - {method?.warnings?.map((warning) => ( - - {warning} - - ))} - - {method ? ( -
-

{method.guidanceMd}

- {entry.docsUrl || method.consoleLinks?.docs ? ( - - Review {entry.name} setup requirements - - - ) : null} -
- ) : null} - {usingVercel && vercelReview && vercelConnectAvailability ? (
@@ -3065,8 +3040,6 @@ function KeyStep({
) : null} - - {standardConfigFields.map((field) => ( ) : null} - {usingVercel ? null : method && fields.length === 0 ? ( -

- {method.auth === "oauth" - ? `You’ll continue to ${entry.name} to sign in securely.` - : "This app doesn’t need a key. Just connect to continue."} -

- ) : ( + {usingVercel || !method || fields.length === 0 ? null : ( fields.map((field) => (
-
- - {method?.auth === "oauth" - ? "You’ll sign in before anything turns on." - : "We’ll check the key before turning anything on."} - - -
+
); @@ -3286,7 +3235,6 @@ function OAuthClientFields({ className="mt-2 h-11 font-mono" />
-
); } @@ -3380,7 +3328,8 @@ export function AccessStep({ setInstallAgentIds: (ids: Set) => void; /** Task-hosted intents grant reach only to the agent that requested it. */ lockedAgentId?: string; - capabilities?: Pick & { + capabilities?: Pick & { + organizationGrantReason?: string | null; companyInstallReason?: string | null; editableAgentIds?: string[]; } | null; @@ -3409,15 +3358,19 @@ export function AccessStep({ // available the option stays visible and disabled with the reason, so the // scope stays legible instead of quietly disappearing. const canSetCompanyInstall = capabilities?.canSetCompanyInstall ?? true; + const canCreateOrganizationGrant = capabilities?.canCreateOrganizationGrant ?? true; const needsIdentityChoice = authKind !== "none"; const allowedGrantKinds = grantKinds ?? (["user", "organization"] satisfies ConnectionGrantKind[]); - const canContinue = preserveAgentAccess + const identityChoiceAllowed = !needsIdentityChoice + || grantKind === "user" + || canCreateOrganizationGrant; + const canContinue = identityChoiceAllowed && (preserveAgentAccess ? true : lockedAgentId ? installAgentIds.has(lockedAgentId) : installChoice === "all" ? canSetCompanyInstall - : installAgentIds.size > 0; + : installAgentIds.size > 0); const lockedAgentName = lockedAgentId ? allAgents.find((agent) => agent.id === lockedAgentId)?.name ?? "the requesting agent" : null; @@ -3460,6 +3413,15 @@ export function AccessStep({ value: "organization", title: "Any human in the company", icon:
- toggleMutation.mutate({ enableApps: checked })} - disabled={toggleMutation.isPending} - settingKey="enableApps" - managed={managedKeys.enableApps} - ariaLabel="Toggle apps experimental setting" - /> - { if (!activeTab) return; setBreadcrumbs([ - { label: selectedCompany?.name ?? "Organization", href: "/dashboard" }, - { label: "Apps", href: "/apps" }, + { label: "Connectors", href: "/apps" }, { label: appName, href: appTabHref(connectionId, "setup") }, { label: appTabLabel(activeTab) }, ]); return () => setBreadcrumbs([]); - }, [setBreadcrumbs, selectedCompany?.name, appName, connectionId, activeTab]); + }, [setBreadcrumbs, appName, connectionId, activeTab]); const catalog = catalogQuery.data?.catalog ?? []; const profile = useMemo( diff --git a/ui/src/pages/apps/AppNotConnected.tsx b/ui/src/pages/apps/AppNotConnected.tsx index 47ab3a73b3..ba0a5c8565 100644 --- a/ui/src/pages/apps/AppNotConnected.tsx +++ b/ui/src/pages/apps/AppNotConnected.tsx @@ -42,7 +42,7 @@ export function AppNotConnected() { const navigate = useNavigate(); const queryClient = useQueryClient(); const { pushToast } = useToast(); - const { selectedCompany, selectedCompanyId } = useCompany(); + const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const activeTab: AppTabKey | null = isAppTabKey(tab) ? tab : null; @@ -115,13 +115,12 @@ export function AppNotConnected() { useEffect(() => { if (!activeTab) return; setBreadcrumbs([ - { label: selectedCompany?.name ?? "Organization", href: "/dashboard" }, - { label: "Apps", href: "/apps" }, + { label: "Connectors", href: "/apps" }, { label: appName, href: appApplicationTabHref(applicationId, "setup") }, { label: appTabLabel(activeTab) }, ]); return () => setBreadcrumbs([]); - }, [setBreadcrumbs, selectedCompany?.name, appName, applicationId, activeTab]); + }, [setBreadcrumbs, appName, applicationId, activeTab]); const remove = useMutation({ mutationFn: () => toolsApi.updateApplication(applicationId, { status: "archived" }), diff --git a/ui/src/pages/apps/AppsConnect.test.tsx b/ui/src/pages/apps/AppsConnect.test.tsx index d56c9ebb88..a27733c097 100644 --- a/ui/src/pages/apps/AppsConnect.test.tsx +++ b/ui/src/pages/apps/AppsConnect.test.tsx @@ -17,6 +17,8 @@ const connectAppMock = vi.hoisted(() => vi.fn()); const startOAuthMock = vi.hoisted(() => vi.fn()); const finishAppMock = vi.hoisted(() => vi.fn()); const putConnectionInstallsMock = vi.hoisted(() => vi.fn()); +const getCloudConnectorEnrollmentMock = vi.hoisted(() => vi.fn()); +const startCloudConnectorEnrollmentMock = vi.hoisted(() => vi.fn()); const listAgentsMock = vi.hoisted(() => vi.fn()); const mockNavigate = vi.hoisted(() => vi.fn()); const navigateTopLevelMock = vi.hoisted(() => vi.fn()); @@ -31,6 +33,7 @@ const POSTHOG = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "posthog" const POSTMAN = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "postman")!; const SHOPIFY = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "shopify")!; const GOOGLE_SHEETS = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "google-sheets")!; +const GOOGLE_CALENDAR = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "google-calendar")!; const GOOGLE_DRIVE = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "google-drive")!; const GMAIL = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "gmail")!; const PAGERDUTY = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "pagerduty")!; @@ -46,6 +49,9 @@ vi.mock("@/api/tools", () => ({ finishAppMock(companyId, connectionId, input), putConnectionInstalls: (connectionId: string, installs: unknown) => putConnectionInstallsMock(connectionId, installs), + getCloudConnectorEnrollment: () => getCloudConnectorEnrollmentMock(), + startCloudConnectorEnrollment: (companyId: string, label?: string, returnTo?: string) => + startCloudConnectorEnrollmentMock(companyId, label, returnTo), }, })); @@ -176,6 +182,7 @@ async function gotoLinkFrame(container: HTMLDivElement, url: string) { buttonByText("Continue")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await flushReact(); + await passAccessStep(); } describe("AppsConnect — Connect with a link (M4 frame)", () => { @@ -195,6 +202,8 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { GITHUB, ], capabilities: { + canCreateOrganizationGrant: true, + organizationGrantReason: null, canSetCompanyInstall: true, companyInstallReason: null, }, @@ -209,6 +218,23 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { }); finishAppMock.mockResolvedValue({}); putConnectionInstallsMock.mockResolvedValue({ connectionId: "conn-1", installs: [] }); + getCloudConnectorEnrollmentMock.mockResolvedValue({ + configured: true, + status: "active", + brokerBaseUrl: "https://my-staging.paperclip.app", + instanceId: "inst-test", + environment: "staging", + origins: ["https://paperclip.example.test"], + }); + startCloudConnectorEnrollmentMock.mockResolvedValue({ + configured: false, + status: "pending", + brokerBaseUrl: "https://my-staging.paperclip.app", + instanceId: "inst-test", + environment: "staging", + origins: [], + verificationUrl: "https://my-staging.paperclip.app/connections/enroll?id=enroll-test", + }); connectAppMock.mockResolvedValue({ connectionId: "conn-1", application: { id: "app-1", name: "example.com" }, @@ -264,7 +290,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { expect(document.activeElement).toBe(urlInput); }); - it("an unrecognized URL routes to a frame with the URL, defaulted Name, and a Yes/No toggle", async () => { + it("an unrecognized URL routes to a minimal frame with the URL and key choice", async () => { await render(); await gotoLinkFrame(container, "https://www.example.com/actions"); @@ -277,11 +303,29 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { expect(buttonByText("No")).toBeTruthy(); expect(buttonByText("Yes")).toBeTruthy(); - // Name is auto-filled from the host with www. stripped. - const nameInput = Array.from(container.querySelectorAll("input")).find( - (i) => i.getAttribute("placeholder") === "My app", - ); - expect(nameInput?.value).toBe("example.com/actions"); + expect(container.querySelector('input[placeholder="My app"]')).toBeNull(); + }); + + it("opens the shared Access step before configuring a pasted MCP server", async () => { + await render(); + + const linkInput = Array.from( + container.querySelectorAll("input"), + ).find((input) => input.getAttribute("placeholder")?.startsWith("https://")); + await act(async () => setInputValue(linkInput!, "https://mcp.example.com/actions")); + await flushReact(); + await act(async () => { + buttonByText("Continue")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + expect(container.textContent).toContain("Which humans can use this credential?"); + expect(container.textContent).toContain("Which agents can use this connection?"); + expect(radioContaining("Just me")).toBeTruthy(); + expect(radioContaining("Any human in the company")?.getAttribute("aria-checked")).toBe("true"); + expect(radioContaining("Just agents I pick")).toBeTruthy(); + expect(radioContaining("Any agent")?.getAttribute("aria-checked")).toBe("true"); + expect(container.textContent).not.toContain("Does it need a key?"); }); // ------------------------------------------------------------------------- @@ -511,7 +555,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { expect(container.textContent).toContain("Your OAuth app"); expect(container.textContent).toContain("Open Asana app settings"); - expect(container.textContent).toContain("Create an Asana MCP OAuth app"); + expect(container.textContent).not.toContain("Create an Asana MCP OAuth app"); expect(container.textContent).toContain("Paperclip callback URL"); expect(container.textContent).toContain( "http://localhost:3000/api/tools/oauth/callback", @@ -614,6 +658,46 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { expect(mockNavigate).not.toHaveBeenCalledWith("/apps/connect", { replace: true }); }); + it("prompts for one-time Cloud registration inside Gmail when managed methods are not yet advertised", async () => { + mockParams.appKey = "gmail"; + listGalleryMock.mockResolvedValue({ + apps: [{ + ...GMAIL, + methods: GMAIL.methods.filter((method) => !method.oauthStrategy), + ownershipAvailability: { platform_shared: false, customer: true, dcr: true }, + }], + }); + getCloudConnectorEnrollmentMock.mockResolvedValueOnce({ + configured: false, + status: "not_configured", + brokerBaseUrl: "https://my-staging.paperclip.app", + instanceId: null, + environment: "staging", + origins: [], + }); + + await render(); + await passAccessStep(); + + expect(container.textContent).toContain("Connect with Paperclip"); + expect(container.textContent).not.toContain("Required once for managed Google sign-in."); + expect(container.textContent).not.toContain("Your OAuth app"); + + await act(async () => { + buttonByText("Connect with Paperclip")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + expect(startCloudConnectorEnrollmentMock).toHaveBeenCalledWith( + "company-1", + "Paperclip", + "/apps/connect?source=gmail&stage=setup", + ); + expect(navigateTopLevelMock).toHaveBeenCalledWith( + "https://my-staging.paperclip.app/connections/enroll?id=enroll-test", + ); + }); + it("keeps Google Drive prerequisites off access and defaults to its write-capable method", async () => { mockParams.appKey = "google-drive"; listGalleryMock.mockResolvedValue({ apps: [GOOGLE_DRIVE] }); @@ -624,14 +708,44 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { expect(container.textContent).not.toContain("does not enable unrelated Paperclip customers"); expect(container.textContent).not.toContain("final project-registration email"); expect(container.textContent).not.toContain("Apply or verify Developer Preview enrollment"); + expect(radioContaining("Just me")).toBeTruthy(); + expect(radioContaining("Any human in the company")?.getAttribute("aria-checked")).toBe("true"); await passAccessStep(); expect(radioContaining("Read & create")?.getAttribute("aria-checked")).toBe("true"); expect(radioContaining("Read only")?.getAttribute("aria-checked")).toBe("false"); - expect(container.textContent).toContain("Before connecting, enroll the signed-in Workspace account"); + expect(container.textContent).not.toContain("Before connecting, enroll the signed-in Workspace account"); + expect(container.textContent).toContain("Review requirements"); expect(container.textContent).toContain("Your OAuth app"); }); + it("renders Google Calendar as one minimal, unboxed setup screen", async () => { + mockParams.appKey = "google-calendar"; + listGalleryMock.mockResolvedValue({ apps: [GOOGLE_CALENDAR] }); + + await render(); + await passAccessStep(); + + const duplicateHeadings = Array.from(container.querySelectorAll("h1, h2")).filter( + (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).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."); + expect(container.textContent).not.toContain("Google Workspace MCP servers are in Developer Preview."); + expect(container.textContent).not.toContain("We filled this in from the app."); + expect(container.textContent).not.toContain("You’ll continue to Google Calendar"); + expect(container.textContent).not.toContain("You’ll sign in before anything turns on."); + expect(container.querySelector('input[placeholder="My app"]')).toBeNull(); + + const capabilityQuestion = Array.from(container.querySelectorAll("label")).find( + (label) => label.textContent === "What should Paperclip be able to do?", + ); + expect(capabilityQuestion?.closest(".max-w-xl")?.classList.contains("bg-card")).toBe(false); + }); + it("keeps Shopify prerequisite copy off access before collecting the store domain", async () => { mockParams.appKey = "shopify"; listGalleryMock.mockResolvedValue({ apps: [SHOPIFY] }); @@ -661,6 +775,8 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { listGalleryMock.mockResolvedValueOnce({ apps: [GITHUB], capabilities: { + canCreateOrganizationGrant: false, + organizationGrantReason: "Only connection managers can share this credential.", canSetCompanyInstall: false, companyInstallReason: "Your company policy limits this choice to connection managers.", }, @@ -684,6 +800,12 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { expect(anyAgent?.getAttribute("aria-label")).toContain( "Your company policy limits this choice to connection managers.", ); + const organization = radioContaining("Any human in the company"); + expect(organization?.disabled).toBe(true); + expect(organization?.getAttribute("title")).toBe( + "Only connection managers can share this credential.", + ); + expect(radioContaining("Just me")?.disabled).toBe(false); // "Just agents I pick" is the live alternative, so the step is not a dead end. const pick = Array.from( document.body.querySelectorAll('[role="radio"]'), @@ -1735,7 +1857,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { expect(input.credentialValues).toBeUndefined(); }); - it("choosing Yes reveals one masked key field plus the lock reassurance", async () => { + it("choosing Yes reveals one masked key field without extra reassurance copy", async () => { await render(); await gotoLinkFrame(container, "https://www.example.com/actions"); @@ -1755,7 +1877,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { container.querySelectorAll("input"), ).filter((i) => i.type === "password"); expect(passwordInputs).toHaveLength(1); - expect(container.textContent).toContain("Stored securely."); + expect(container.textContent).not.toContain("Stored securely."); await act(async () => setInputValue(passwordInputs[0], "secret-key")); await flushReact(); @@ -1789,10 +1911,14 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { }); await flushReact(); + expect(container.textContent).toContain("Which humans can use this credential?"); + expect(container.textContent).toContain("Which agents can use this connection?"); + await passAccessStep(); + expect(container.textContent).toContain("Connect your own MCP server"); expect(container.textContent).not.toContain(zapierUrl); expect(container.textContent).toContain("token=REDACTED"); - expect(nameInputFrom(container)?.value).toBe("Zapier"); + expect(container.querySelector('input[placeholder="My app"]')).toBeNull(); expect(container.querySelector('input[type="password"]')).toBeNull(); await act(async () => { @@ -1810,7 +1936,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { expect(connectAppMock.mock.calls[0]?.[1].credentialValues).toBeUndefined(); }); - it("keeps Zapier visible and finishes without a separate access or install step", async () => { + it("gives Zapier the shared credential and agent access opener", async () => { mockSearch.value = "source=zapier"; listGalleryMock.mockResolvedValueOnce({ apps: [ @@ -1845,14 +1971,22 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { }); await render(); - // The pasted-URL path enters its server address in this step, so an identity - // question cannot precede it; it keeps today's every-agent default rather - // than asking (PAP-17835 leaves the Access step to the curated app path). - expect(container.textContent).toContain("Step 1 of 1"); + expect(container.textContent).toContain("Step 1 of 2"); expect(container.textContent).toContain("Connect Zapier"); - expect(container.textContent).toContain("Add MCP URL"); + expect(container.textContent).toContain("Which humans can use this credential?"); + expect(container.textContent).toContain("Which agents can use this connection?"); expect(container.querySelector('img[src="https://example.com/zapier.png"]')).toBeTruthy(); expect(container.textContent).not.toContain("Pick the app you want your agents to use."); + expect(container.querySelector('input[placeholder^="https://mcp.zapier.com"]')).toBeNull(); + + await act(async () => { + radioContaining("Just me")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + await passAccessStep(); + + expect(container.textContent).toContain("Step 2 of 2"); + expect(container.textContent).toContain("Add MCP URL"); const linkInput = container.querySelector( 'input[placeholder^="https://mcp.zapier.com"]', @@ -1871,13 +2005,11 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { expect(connectAppMock).toHaveBeenCalledTimes(1); expect(connectAppMock.mock.calls[0]?.[1]).toMatchObject({ link: zapierUrl, - name: "Zapier for the company", + name: "Zapier", galleryKey: "zapier", connectionMethodKey: "generated-url", + grantKind: "user", }); - // No grantKind is sent: the pasted-URL path never offered the choice, and - // sending "user" without asking would mis-scope the credential. - expect(connectAppMock.mock.calls[0]?.[1]).not.toHaveProperty("grantKind"); expect(finishAppMock).toHaveBeenCalledWith("company-1", "conn-1", { enabledCatalogEntryIds: ["action-1", "action-2"], @@ -1922,12 +2054,12 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { "link=https%3A%2F%2Fwww.example.com%2Factions&name=Bla&applicationId=app-77"; await render(); + expect(container.textContent).toContain("Which humans can use this credential?"); + await passAccessStep(); + expect(container.textContent).toContain("Connect your own MCP server"); expect(container.textContent).toContain("https://www.example.com/actions"); - const nameInput = Array.from(container.querySelectorAll("input")).find( - (i) => i.getAttribute("placeholder") === "My app", - ); - expect(nameInput?.value).toBe("Bla"); + expect(container.querySelector('input[placeholder="My app"]')).toBeNull(); await act(async () => { buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); @@ -1998,15 +2130,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { expect(connectAppMock).not.toHaveBeenCalled(); }); - // PAP-11283: the gallery step exposes a Name field (default = app name) so a - // connection can be named at create time, matching the link flow. - function nameInputFrom(root: HTMLDivElement): HTMLInputElement | undefined { - return Array.from(root.querySelectorAll("input")).find( - (i) => i.getAttribute("placeholder") === "My app", - ); - } - - it("gallery key step defaults the Name field to the app name", async () => { + it("gallery setup names the connection automatically", async () => { await render(); await act(async () => { @@ -2016,7 +2140,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { await passAccessStep(); expect(container.textContent).toContain("Connect GitHub"); - expect(nameInputFrom(container)?.value).toBe("GitHub"); + expect(container.querySelector('input[placeholder="My app"]')).toBeNull(); expect(mockNavigate).toHaveBeenCalledWith("/apps/connect?source=github&stage=setup"); }); @@ -2108,7 +2232,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { await render(); await passAccessStep(); - expect(nameInputFrom(container)?.value).toBe("Engineering GitHub for the company"); + expect(container.querySelector('input[placeholder="My app"]')).toBeNull(); const keyField = container.querySelector("input[type=password]"); await act(async () => setInputValue(keyField!, "replacement-key")); await flushReact(); @@ -2127,7 +2251,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { }); }); - it("a custom name in the gallery step is sent to the connect mutation", async () => { + it("uses the gallery app name automatically", async () => { await render(); await act(async () => { @@ -2136,7 +2260,6 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { await flushReact(); await passAccessStep(); - await act(async () => setInputValue(nameInputFrom(container)!, "GitHub (stdio smoke)")); const keyField = container.querySelector("input[type=password]"); await act(async () => setInputValue(keyField!, "secret-key")); await flushReact(); @@ -2149,11 +2272,11 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { const [, input] = connectAppMock.mock.calls[0]; expect(input).toMatchObject({ galleryKey: "github", - name: "GitHub (stdio smoke) for the company", + name: "GitHub for the company", }); }); - it("a custom name on the Google Sheets step is sent to the connect mutation", async () => { + it("uses the Google Sheets app name automatically", async () => { listGalleryMock.mockResolvedValueOnce({ apps: [ { ...GOOGLE_SHEETS, availability: { available: true, robotEmail: "robot@paperclip.iam.gserviceaccount.com" } }, @@ -2171,9 +2294,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { }); await flushReact(); - // Default is the app name. - expect(nameInputFrom(container)?.value).toBe("Google Sheets"); - await act(async () => setInputValue(nameInputFrom(container)!, "Google Sheets (stdio smoke)")); + expect(container.querySelector('input[placeholder="My app"]')).toBeNull(); const textarea = container.querySelector("textarea"); await act(async () => setTextareaValue(textarea!, "https://docs.google.com/spreadsheets/d/sheet_123/edit"), @@ -2188,7 +2309,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { const [, input] = connectAppMock.mock.calls[0]; expect(input).toMatchObject({ galleryKey: "google-sheets", - name: "Google Sheets (stdio smoke) for the company", + name: "Google Sheets for the company", configValues: { allowedSpreadsheetIds: ["sheet_123"] }, }); }); @@ -2308,12 +2429,18 @@ describe("AppsConnect — guided generic MCP flow (PAP-17087)", () => { return new ApiError(message, status, { error: message, details: { code } }); } - it("defaults the connection name from host, port, and path", async () => { + it("defaults the hidden connection name from host, port, and path", async () => { await render(); await gotoLinkFrame(container, "http://127.0.0.1:47399/mcp"); - expect(container.querySelector("#generic-mcp-name")?.value) - .toBe("127.0.0.1:47399/mcp"); + expect(container.querySelector("#generic-mcp-name")).toBeNull(); + await act(async () => { + buttonByText("Check link")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + expect(connectAppMock.mock.calls[0]?.[1]).toMatchObject({ + name: "127.0.0.1:47399/mcp for the company", + }); }); it("keeps the endpoint host visible while skipping action review", async () => { @@ -2436,7 +2563,7 @@ describe("AppsConnect — guided generic MCP flow (PAP-17087)", () => { expect(container.textContent).not.toContain("PAPERCLIP_PUBLIC_URL"); }); - it("renders a name conflict as name guidance and focuses the Name field", async () => { + it("does not ask the operator to resolve an internal name conflict", async () => { connectAppMock.mockRejectedValue(apiError( 409, "tool_access_name_conflict", @@ -2450,9 +2577,9 @@ describe("AppsConnect — guided generic MCP flow (PAP-17087)", () => { await flushReact(); await flushReact(); - const nameInput = container.querySelector("#generic-mcp-name"); - expect(container.textContent).toContain("That name is taken"); - expect(document.activeElement).toBe(nameInput); + expect(container.textContent).toContain("Paperclip couldn’t name this connection"); + expect(container.textContent).not.toContain("Choose a different name"); + expect(container.querySelector("#generic-mcp-name")).toBeNull(); }); it("opens advanced authentication when the server wants a credential we can't discover", async () => { diff --git a/ui/src/pages/apps/AppsReview.tsx b/ui/src/pages/apps/AppsReview.tsx index 48b09298ef..6728eda7e7 100644 --- a/ui/src/pages/apps/AppsReview.tsx +++ b/ui/src/pages/apps/AppsReview.tsx @@ -14,17 +14,16 @@ import { ReviewQueueCard } from "./ReviewQueueCard"; * "Needs attention"; decisions live here. */ export function AppsReview() { - const { selectedCompany, selectedCompanyId } = useCompany(); + const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); useEffect(() => { setBreadcrumbs([ - { label: selectedCompany?.name ?? "Organization", href: "/dashboard" }, - { label: "Apps", href: "/apps" }, + { label: "Connectors", href: "/apps" }, { label: "Review" }, ]); return () => setBreadcrumbs([]); - }, [setBreadcrumbs, selectedCompany?.name]); + }, [setBreadcrumbs]); if (!selectedCompanyId) { return
Select an organization to review approvals.
; diff --git a/ui/src/pages/apps/Browse.test.tsx b/ui/src/pages/apps/Browse.test.tsx index bfa71d6773..b4f5ee4778 100644 --- a/ui/src/pages/apps/Browse.test.tsx +++ b/ui/src/pages/apps/Browse.test.tsx @@ -13,6 +13,7 @@ const listUserDirectoryMock = vi.hoisted(() => vi.fn()); const archiveConnectionMock = vi.hoisted(() => vi.fn()); const pushToastMock = vi.hoisted(() => vi.fn()); const navigateMock = vi.hoisted(() => vi.fn()); +const setBreadcrumbsMock = vi.hoisted(() => vi.fn()); vi.mock("@/api/tools", () => ({ toolsApi: { @@ -45,7 +46,7 @@ vi.mock("@/context/CompanyContext", () => ({ })); vi.mock("@/context/BreadcrumbContext", () => ({ - useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }), + useBreadcrumbs: () => ({ setBreadcrumbs: setBreadcrumbsMock }), })); vi.mock("@/context/ToastContext", () => ({ @@ -173,6 +174,10 @@ describe("Connectors landing page", () => { it("renders one connector list with the requested header and no gallery sections", async () => { await renderBrowse(); + expect(setBreadcrumbsMock).toHaveBeenCalledWith([]); + expect(setBreadcrumbsMock).not.toHaveBeenCalledWith(expect.arrayContaining([ + expect.objectContaining({ href: "/dashboard" }), + ])); expect(container.querySelector("header")?.textContent).toBe("Connectors"); expect( container.querySelector('header input[aria-label="Search connectors"]'), diff --git a/ui/src/pages/apps/Browse.tsx b/ui/src/pages/apps/Browse.tsx index 5578b5ad98..7210fbbdfa 100644 --- a/ui/src/pages/apps/Browse.tsx +++ b/ui/src/pages/apps/Browse.tsx @@ -203,18 +203,18 @@ export function Browse() { const navigate = useNavigate(); const queryClient = useQueryClient(); const { pushToast } = useToast(); - const { selectedCompany, selectedCompanyId } = useCompany(); + const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const [query, setQuery] = useState(""); const [connectionToRemove, setConnectionToRemove] = useState(null); useEffect(() => { - setBreadcrumbs([ - { label: selectedCompany?.name ?? "Organization", href: "/dashboard" }, - { label: "Connectors" }, - ]); + // Apps is already its own navigation root and the page has its own title. + // Leave the global bar empty instead of duplicating "Connectors" or + // offering a path back out of Apps. + setBreadcrumbs([]); return () => setBreadcrumbs([]); - }, [setBreadcrumbs, selectedCompany?.name]); + }, [setBreadcrumbs]); const galleryQuery = useQuery({ queryKey: queryKeys.apps.gallery(selectedCompanyId ?? "__none__"), diff --git a/ui/src/pages/apps/Connections.tsx b/ui/src/pages/apps/Connections.tsx index 3cc355276c..69c584adbb 100644 --- a/ui/src/pages/apps/Connections.tsx +++ b/ui/src/pages/apps/Connections.tsx @@ -126,12 +126,11 @@ export function Connections() { useEffect(() => { setBreadcrumbs([ - { label: selectedCompany?.name ?? "Organization", href: "/dashboard" }, - { label: "Apps", href: "/apps" }, + { label: "Connectors", href: "/apps" }, { label: "Connections" }, ]); return () => setBreadcrumbs([]); - }, [setBreadcrumbs, selectedCompany?.name]); + }, [setBreadcrumbs]); const galleryQuery = useQuery({ queryKey: queryKeys.apps.gallery(selectedCompanyId ?? "__none__"), diff --git a/ui/src/pages/apps/gateways/GatewayDetail.tsx b/ui/src/pages/apps/gateways/GatewayDetail.tsx index d6e7e58e94..74eab6c3b8 100644 --- a/ui/src/pages/apps/gateways/GatewayDetail.tsx +++ b/ui/src/pages/apps/gateways/GatewayDetail.tsx @@ -31,7 +31,7 @@ export function GatewayDetail() { const navigate = useNavigate(); const queryClient = useQueryClient(); const { pushToast } = useToast(); - const { selectedCompany, selectedCompanyId } = useCompany(); + const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const [snippetOpen, setSnippetOpen] = useState(false); const [editing, setEditing] = useState(false); @@ -103,13 +103,12 @@ export function GatewayDetail() { useEffect(() => { if (!gateway) return; setBreadcrumbs([ - { label: selectedCompany?.name ?? "Organization", href: "/dashboard" }, - { label: "Apps", href: "/apps" }, + { label: "Connectors", href: "/apps" }, { label: "Gateways", href: "/apps/gateways" }, { label: gateway.name }, ]); return () => setBreadcrumbs([]); - }, [setBreadcrumbs, selectedCompany?.name, gateway]); + }, [setBreadcrumbs, gateway]); const toggleMutation = useMutation({ mutationFn: () => diff --git a/ui/src/pages/apps/gateways/GatewaysList.tsx b/ui/src/pages/apps/gateways/GatewaysList.tsx index d0dcc8330c..44ef05dc97 100644 --- a/ui/src/pages/apps/gateways/GatewaysList.tsx +++ b/ui/src/pages/apps/gateways/GatewaysList.tsx @@ -32,19 +32,18 @@ export function GatewaysList() { const navigate = useNavigate(); const queryClient = useQueryClient(); const { pushToast } = useToast(); - const { selectedCompany, selectedCompanyId } = useCompany(); + const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const [search, setSearch] = useState(""); const [creating, setCreating] = useState(false); useEffect(() => { setBreadcrumbs([ - { label: selectedCompany?.name ?? "Organization", href: "/dashboard" }, - { label: "Apps", href: "/apps" }, + { label: "Connectors", href: "/apps" }, { label: "Gateways" }, ]); return () => setBreadcrumbs([]); - }, [setBreadcrumbs, selectedCompany?.name]); + }, [setBreadcrumbs]); const gatewaysQuery = useQuery({ queryKey: gatewaysQueryKey(selectedCompanyId ?? "__none__"), diff --git a/ui/src/pages/apps/generic-mcp-connect.test.ts b/ui/src/pages/apps/generic-mcp-connect.test.ts index f13192eae8..e32f8776ca 100644 --- a/ui/src/pages/apps/generic-mcp-connect.test.ts +++ b/ui/src/pages/apps/generic-mcp-connect.test.ts @@ -90,9 +90,10 @@ describe("genericConnectGuidance", () => { expect(genericConnectGuidance("oauth_redirect_origin_unsupported", null).focus).toBe("deployment"); }); - it("points at the name field when the connection name is already used", () => { + it("does not ask the operator to resolve an internal name conflict", () => { const guidance = genericConnectGuidance("tool_access_name_conflict", null); - expect(guidance).toMatchObject({ title: "That name is taken", focus: "name" }); + expect(guidance).toMatchObject({ title: "Paperclip couldn’t name this connection", focus: "none" }); + expect(guidance.body).not.toContain("different name"); }); it("passes a rejected header's own message through", () => { diff --git a/ui/src/pages/apps/generic-mcp-connect.ts b/ui/src/pages/apps/generic-mcp-connect.ts index 98ea9dcaec..d98eca617a 100644 --- a/ui/src/pages/apps/generic-mcp-connect.ts +++ b/ui/src/pages/apps/generic-mcp-connect.ts @@ -78,7 +78,7 @@ export function defaultGenericMcpName(url: string): string | null { * this to decide whether to reopen the URL field or the credential section, rather * than leaving them to guess which of the two was wrong. */ -export type GenericConnectFocus = "url" | "name" | "credentials" | "deployment" | "none"; +export type GenericConnectFocus = "url" | "credentials" | "deployment" | "none"; export interface GenericConnectGuidance { title: string; @@ -126,9 +126,9 @@ export function genericConnectGuidance( }; case "tool_access_name_conflict": return { - title: "That name is taken", - body: "Choose a different name for this connection, then try again.", - focus: "name", + title: "Paperclip couldn’t name this connection", + body: "Try connecting again.", + focus: "none", }; case "oauth_challenge": return { diff --git a/ui/storybook/stories/permitted-vs-installed.stories.tsx b/ui/storybook/stories/permitted-vs-installed.stories.tsx index 01bd259481..b56d76cc97 100644 --- a/ui/storybook/stories/permitted-vs-installed.stories.tsx +++ b/ui/storybook/stories/permitted-vs-installed.stories.tsx @@ -269,13 +269,21 @@ function SeededAccessStep({ initialGrantKind, initialChoice, initialAgentIds, - capabilities = { canSetCompanyInstall: true, editableAgentIds: AGENT_IDS }, + capabilities = { + canCreateOrganizationGrant: true, + canSetCompanyInstall: true, + editableAgentIds: AGENT_IDS, + }, }: { authKind: "oauth" | "api_key" | "none"; initialGrantKind: "user" | "organization"; initialChoice: "specific" | "all"; initialAgentIds: Set; - capabilities?: { canSetCompanyInstall: boolean; editableAgentIds: string[] }; + capabilities?: { + canCreateOrganizationGrant: boolean; + canSetCompanyInstall: boolean; + editableAgentIds: string[]; + }; }) { const client = useMemo(() => { const c = new QueryClient({