From 9c03443c488672cfb57281b0c45d5c59502edba2 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Tue, 25 Aug 2026 16:06:35 -0700 Subject: [PATCH] feat: hideable company settings pages, with import floored on cloud-managed instances (#12199) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The app can run self-hosted or as a cloud-managed instance, where a hosting platform provisions the instance with its company already materialized (the existing `isCloudManagedInstance()` predicate and `cloud_managed` floors) > - The company Import/Export surface lets an operator materialize whole companies from an export bundle; on a cloud-managed instance this bypasses the existing `cloud_managed` company-creation floor and conflicts with platform-owned provisioning > - Importing should be disabled on cloud-managed instances, while export stays open as the data-portability escape hatch > - This pull request floors every import route with 403 `code=cloud_managed` on cloud-managed instances and hides the Import UI there, using the existing predicate and the established floor pattern > - It also extends the operator-hidden settings registry with keys for every top-level company settings page, so a hosting operator can hide any of them with `PAPERCLIP_HIDDEN_SETTINGS` alone next time > - The benefit is one consistent managed-instance policy: cloud-managed instances cannot import companies, self-hosted installs keep the full import surface unchanged ## Linked Issues or Issue Description No public issue exists; the underlying problem follows the enhancement template. **What existing behavior does this improve?** The company import surface (`/api/companies/import*`, `/api/companies/:companyId/imports/*`) and its UI entry points on cloud-managed instances. **Subsystem affected** Server routes (`server/src/routes/companies.ts`) and UI navigation/pages (settings sidebar, settings tabs, org chart, `/company/import` route). **Current behavior** A cloud-managed instance floors direct company creation (`POST /api/companies` answers 403 `cloud_managed`), but the import routes still accept company bundles, so an import can materialize companies the hosting platform did not provision. The UI offers Import entry points that lead to a surface that is not available on cloud-managed instances. **Proposed behavior** On instances where `isCloudManagedInstance()` is true, every import route answers 403 `code=cloud_managed` before auth and body work, and the Import UI (sidebar entry, settings tab, org-chart button, `/company/import` route) is hidden or redirected. Export remains fully available. Self-hosted instances are unchanged. **Reason and benefit** Cloud-managed instances keep one consistent provisioning authority, and users do not see an Import surface that dead-ends in a 403. ## What Changed - `server/src/routes/companies.ts`: a router-level floor mounted at the `/import` and `/:companyId/imports` prefixes. It covers the single-shot upload, preview, job polling, chunked transfer declare/part-upload/status/preview/apply, and the agent-safe per-company import routes. It throws `forbidden(..., { code: "cloud_managed" })` on cloud-managed instances, or `403 settings_operator_managed` when the operator hides `company.import` — both before auth and body validation, mirroring the company-creation floor. - `packages/shared/src/settings-visibility.ts`: new `HIDEABLE_COMPANY_PAGES` registry group — `company.members`, `company.invites`, `company.secrets`, `company.export`, `company.import` — with a `hidesCompanyPage` helper. The company General page stays non-hideable (settings root). `company.import` floors its API; the other keys are UI-visibility only, as documented in the registry, so membership/invite/secret/export APIs stay live for agents. - `ui/src/components/CloudManagedPageGate.tsx` (new): route gate that redirects cloud-managed instances to `/company/settings`, modeled on `HiddenSettingsPageGate`. - `ui/src/App.tsx`: wraps the `company/import` route in `CloudManagedPageGate`. - `ui/src/components/CompanySettingsSidebar.tsx`, `ui/src/components/access/CompanySettingsNav.tsx`, `ui/src/pages/OrgChart.tsx`: hide the Import entry points when `useCloudInstance()` reports a managed instance, and honor the new `company.*` hidden-settings keys for every company page entry (sidebar item, tab, org-chart buttons). - `ui/src/App.tsx`: `HiddenSettingsPageGate` route gates for the members (incl. the legacy access route), invites, secrets, export, and import pages under their `company.*` keys. - `docs/deploy/environment-variables.md`: documents the new keys and their semantics; the CLI and board-operator guides note that import is unavailable on cloud-managed instances. - Tests: new `server/src/__tests__/company-import-cloud-floor.test.ts` and `ui/src/components/CloudManagedPageGate.test.tsx`, registry cases in `packages/shared/src/settings-visibility.test.ts`, plus cloud and hidden-key cases in the sidebar, settings-nav, and org-chart suites. ## Verification - TypeScript typechecks pass for every workspace package (`tsc` in shared, server, ui; the runner's Rust leg needs a local cargo toolchain and is covered by CI). - `pnpm test` on this branch fails only in 9 files that also fail on a clean `origin/master` checkout on the same machine (environment-dependent suites: live-listener probes, workspace/native-runtime spawns, skill materialization). Zero branch-only failures against that baseline; every suite touched by this change passes. - `server/src/__tests__/company-import-cloud-floor.test.ts` asserts: every import route answers 403 `cloud_managed` under the server-token signal; the managed-config signal alone also floors; every import route answers 403 `settings_operator_managed` when `company.import` is hidden; hiding other company pages leaves import open; the floor applies before auth and body validation; export stays open on cloud-managed instances; self-hosted import preview and job polling still work. - `packages/shared/src/settings-visibility.test.ts` covers the new `company.*` keys and `hidesCompanyPage`. - UI suites assert the Import tab, sidebar entry, and org-chart button disappear on a cloud-managed instance while Export stays, that `/company/import` redirects through the gate, and that the `company.*` keys hide their sidebar entries and tabs. ## Risks - Low risk for self-hosted installs: the floor is inert unless a cloud signal (`PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN` or `PAPERCLIP_MANAGED_CONFIG`) is present, and the self-hosted paths are regression-tested. - On cloud-managed instances this is a deliberate behavioral removal: import (including agent-driven safe imports and resumable transfers) stops working the moment an instance runs this build. In-flight chunked transfers on such instances cannot be applied afterward; they answer 403. - CLI import commands against a cloud-managed instance now fail with the `cloud_managed` error; the message names the reason. - The new `company.*` keys change nothing unless an operator sets them: `PAPERCLIP_HIDDEN_SETTINGS` unset keeps behavior identical, and older images ignore unknown keys by design. The four non-import company keys hide UI only; their APIs stay live, which the registry documents explicitly. ## Model Used Claude Fable 5 (`claude-fable-5`, Anthropic) via Claude Code — agentic coding session with tool use (code search, editing, local test execution). ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --- docs/cli/control-plane-commands.md | 4 + docs/deploy/environment-variables.md | 9 + .../board-operator/importing-and-exporting.md | 2 + packages/shared/src/index.ts | 3 + .../shared/src/settings-visibility.test.ts | 22 ++ packages/shared/src/settings-visibility.ts | 36 ++- .../company-import-cloud-floor.test.ts | 205 ++++++++++++++++++ .../company-portability-routes.test.ts | 20 +- server/src/routes/companies.ts | 39 +++- ui/src/App.tsx | 39 ++-- .../components/CloudManagedPageGate.test.tsx | 72 ++++++ ui/src/components/CloudManagedPageGate.tsx | 17 ++ .../CompanySettingsSidebar.test.tsx | 28 ++- ui/src/components/CompanySettingsSidebar.tsx | 36 ++- .../access/CompanySettingsNav.test.tsx | 48 +++- .../components/access/CompanySettingsNav.tsx | 10 + ui/src/pages/OrgChart.test.tsx | 16 ++ ui/src/pages/OrgChart.tsx | 37 +++- 18 files changed, 589 insertions(+), 54 deletions(-) create mode 100644 server/src/__tests__/company-import-cloud-floor.test.ts create mode 100644 ui/src/components/CloudManagedPageGate.test.tsx create mode 100644 ui/src/components/CloudManagedPageGate.tsx diff --git a/docs/cli/control-plane-commands.md b/docs/cli/control-plane-commands.md index 75f197cc35..06c8143567 100644 --- a/docs/cli/control-plane-commands.md +++ b/docs/cli/control-plane-commands.md @@ -57,6 +57,10 @@ npx paperclipai company import \ --include company,agents ``` +`company import` is unavailable against cloud-managed instances — the +server answers `403` with `code: "cloud_managed"`. Export remains available +there. + With agent authentication, use `company list` or `company current` to resolve the scoped company. `company list` first tries the board-wide list; if that is forbidden, it falls back to `--company-id`, `PAPERCLIP_COMPANY_ID`, context, or diff --git a/docs/deploy/environment-variables.md b/docs/deploy/environment-variables.md index df5b2008f1..dc0bcbe8f3 100644 --- a/docs/deploy/environment-variables.md +++ b/docs/deploy/environment-variables.md @@ -41,6 +41,15 @@ All environment variables that Paperclip uses for server configuration. - Any experimental toggle: `instance.experimental.` (e.g. `instance.experimental.enableSmokeLab`) — the card disappears and value-changing writes are rejected. +- Any top-level company settings page: `company.members`, `company.invites`, + `company.secrets`, `company.export`, `company.import` — removed from the + settings sidebar, tab bar, and routing (the company General page is the + settings root and stays visible). These are UI-visibility keys: the + membership, invite, secret, and export APIs stay live for agents and + integrations. `company.import` is the exception — hiding it also floors + every company-import route with `403 settings_operator_managed`. On + cloud-managed instances import is floored unconditionally with + `403 cloud_managed`, independent of this variable. Unknown keys are logged and ignored, so one list can be rolled across a fleet of mixed app versions. With the variable unset nothing is hidden and behavior diff --git a/docs/guides/board-operator/importing-and-exporting.md b/docs/guides/board-operator/importing-and-exporting.md index 1d8e40d241..46b2929f5a 100644 --- a/docs/guides/board-operator/importing-and-exporting.md +++ b/docs/guides/board-operator/importing-and-exporting.md @@ -33,6 +33,8 @@ my-company/ Both flows are also available in the web UI as company settings pages: **Export** and **Import** appear in the company settings navigation. +> **Cloud-managed instances:** when a hosting platform manages the instance, the company is provisioned by the platform and importing is disabled — the Import page and buttons are hidden, and every import API route answers `403` with `code: "cloud_managed"`. Export stays available, so you can always take your company's data with you. Self-hosted instances keep the full import surface. + The **Export** page lets you pick exactly which files go into the bundle before downloading it. Above the file tree it shows a **"Not included in this export"** panel — the export fidelity report — listing data the bundle will not carry (for example attachments, approvals, cost history, or activity log entries), with blocking issues highlighted. The **Import** page previews the package, lets you resolve name collisions and adapter assignments, and applies the import. A **"Start imported agents and routines paused"** checkbox (on by default) makes imported agents and routines land paused instead of live. After the import finishes, an **"Activate imported agents and routines"** panel lists everything that was imported paused so you can resume the agents and activate the routines you select — nothing starts running until you say so. diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index eba88a35d5..378f8c4a44 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2499,16 +2499,19 @@ export { type InstanceFeatureKey, } from "./feature-catalog.js"; export { + HIDEABLE_COMPANY_PAGES, HIDEABLE_GENERAL_SECTIONS, HIDEABLE_INSTANCE_PAGES, HIDEABLE_SETTING_KEYS, SETTINGS_OPERATOR_MANAGED_ERROR_CODE, UI_ONLY_GENERAL_SECTIONS, experimentalSettingKey, + hidesCompanyPage, hidesExperimentalSetting, hidesGeneralSection, hidesInstancePage, parseHiddenSettingsList, + type HideableCompanyPage, type HideableExperimentalSetting, type HideableGeneralSection, type HideableInstancePage, diff --git a/packages/shared/src/settings-visibility.test.ts b/packages/shared/src/settings-visibility.test.ts index 8bdba67d9e..ece26654ee 100644 --- a/packages/shared/src/settings-visibility.test.ts +++ b/packages/shared/src/settings-visibility.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it } from "vitest"; import { INSTANCE_FEATURE_KEYS } from "./feature-catalog.js"; import { + HIDEABLE_COMPANY_PAGES, HIDEABLE_GENERAL_SECTIONS, HIDEABLE_SETTING_KEYS, UI_ONLY_GENERAL_SECTIONS, experimentalSettingKey, + hidesCompanyPage, hidesExperimentalSetting, hidesGeneralSection, hidesInstancePage, @@ -21,6 +23,19 @@ describe("hideable setting keys", () => { expect(new Set(HIDEABLE_SETTING_KEYS).size).toBe(HIDEABLE_SETTING_KEYS.length); }); + it("covers every top-level company settings page except the General root", () => { + expect(HIDEABLE_COMPANY_PAGES).toEqual([ + "company.members", + "company.invites", + "company.secrets", + "company.export", + "company.import", + ]); + for (const page of HIDEABLE_COMPANY_PAGES) { + expect(HIDEABLE_SETTING_KEYS).toContain(page); + } + }); + it("maps field-backed general sections onto real general-settings fields", () => { const generalFields = Object.keys(instanceGeneralSettingsSchema.shape); const uiOnly = new Set(UI_ONLY_GENERAL_SECTIONS); @@ -57,6 +72,13 @@ describe("membership helpers", () => { ).hidden, ); + it("answers company-page membership", () => { + const companyHidden = new Set(parseHiddenSettingsList("company.import,company.secrets").hidden); + expect(hidesCompanyPage(companyHidden, "company.import")).toBe(true); + expect(hidesCompanyPage(companyHidden, "company.secrets")).toBe(true); + expect(hidesCompanyPage(companyHidden, "company.export")).toBe(false); + }); + it("answers page, section, and experimental membership", () => { expect(hidesInstancePage(hidden, "instance.plugins")).toBe(true); expect(hidesInstancePage(hidden, "instance.adapters")).toBe(false); diff --git a/packages/shared/src/settings-visibility.ts b/packages/shared/src/settings-visibility.ts index 2dd9fe7e5b..f7fa75c583 100644 --- a/packages/shared/src/settings-visibility.ts +++ b/packages/shared/src/settings-visibility.ts @@ -4,14 +4,17 @@ import { INSTANCE_FEATURE_KEYS, type InstanceFeatureKey } from "./feature-catalo * Operator-configurable settings visibility. * * A hosting operator (a managed cloud, an internal shared server) can hide - * instance-settings surfaces that do not apply to their deployment by setting - * the `PAPERCLIP_HIDDEN_SETTINGS` environment variable to a comma-separated + * settings surfaces that do not apply to their deployment by setting the + * `PAPERCLIP_HIDDEN_SETTINGS` environment variable to a comma-separated * list of keys from this registry. Hiding a surface removes it from the UI * (nav, routes, page sections). Surfaces backed by instance-level mutation * routes are also floored with a 403 carrying * `SETTINGS_OPERATOR_MANAGED_ERROR_CODE`: the Access, Plugins, and Adapters - * pages, every field-backed General section, and every experimental toggle - * (individually or via the whole Experimental page). + * pages, every field-backed General section, every experimental toggle + * (individually or via the whole Experimental page), and the company Import + * page (whose whole route surface is floored). The other company pages are + * UI-visibility keys only: their APIs (memberships, invites, secrets, + * exports) stay live for agents and integrations. * * Nothing is hidden by default: with the variable unset, UI and API behave * exactly as before this mechanism existed. @@ -39,6 +42,22 @@ export const HIDEABLE_INSTANCE_PAGES = [ export type HideableInstancePage = (typeof HIDEABLE_INSTANCE_PAGES)[number]; +/** + * Company-level settings pages that can be hidden (nav entry + tab + route). + * The company General page is deliberately not hideable: it is the settings + * root and the redirect target for hidden pages. `company.import` also floors + * the import API routes; the rest only hide UI surfaces. + */ +export const HIDEABLE_COMPANY_PAGES = [ + "company.members", + "company.invites", + "company.secrets", + "company.export", + "company.import", +] as const; + +export type HideableCompanyPage = (typeof HIDEABLE_COMPANY_PAGES)[number]; + /** * Sections of Instance → General that can be hidden. Field-backed sections * (their suffix names a general-settings field) also floor writes to that @@ -70,12 +89,14 @@ export function experimentalSettingKey(key: InstanceFeatureKey): HideableExperim export type HideableSettingKey = | HideableInstancePage + | HideableCompanyPage | HideableGeneralSection | HideableExperimentalSetting; /** Every key `PAPERCLIP_HIDDEN_SETTINGS` accepts. */ export const HIDEABLE_SETTING_KEYS: readonly HideableSettingKey[] = [ ...HIDEABLE_INSTANCE_PAGES, + ...HIDEABLE_COMPANY_PAGES, ...HIDEABLE_GENERAL_SECTIONS, ...INSTANCE_FEATURE_KEYS.map(experimentalSettingKey), ]; @@ -117,6 +138,13 @@ export function hidesInstancePage( return hidden.has(page); } +export function hidesCompanyPage( + hidden: ReadonlySet, + page: HideableCompanyPage, +): boolean { + return hidden.has(page); +} + export function hidesGeneralSection( hidden: ReadonlySet, section: HideableGeneralSection, diff --git a/server/src/__tests__/company-import-cloud-floor.test.ts b/server/src/__tests__/company-import-cloud-floor.test.ts new file mode 100644 index 0000000000..71482ac227 --- /dev/null +++ b/server/src/__tests__/company-import-cloud-floor.test.ts @@ -0,0 +1,205 @@ +import express from "express"; +import request from "supertest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mockPortabilityService = vi.hoisted(() => ({ + previewExport: vi.fn(), + exportBundle: vi.fn(), + previewImport: vi.fn(), + importBundle: vi.fn(), +})); + +const mockTransferRunService = vi.hoisted(() => ({ + resumeOrCreate: vi.fn(), + getRunForActor: vi.fn(), + recordCompletedPart: vi.fn(), + claimApply: vi.fn(), + releaseApplyClaim: vi.fn(), + completeRun: vi.fn(), + failRun: vi.fn(), +})); + +const mockLogActivity = vi.hoisted(() => vi.fn()); + +vi.mock("../services/index.js", () => ({ + accessService: () => ({}), + agentService: () => ({}), + budgetService: () => ({}), + companyArtifactsService: () => ({}), + companyPortabilityService: () => mockPortabilityService, + companyService: () => ({}), + feedbackService: () => ({}), + logActivity: mockLogActivity, + workTimelineService: () => ({}), +})); + +vi.mock("../services/company-transfer-runs.js", () => ({ + companyTransferRunService: mockTransferRunService, +})); + +const TRANSFER_ID = "6e0a4f6e-6f7d-4a37-9a83-0b8f2f9f2b11"; + +/** + * Every route in the company-import surface. The floor must answer all of + * them, including the read-only polling routes: with imports disabled no job + * or transfer can exist, so a uniform 403 is clearer than a mixed surface. + */ +const IMPORT_ROUTES: Array<{ method: "get" | "post" | "put"; path: string }> = [ + { method: "post", path: "/api/companies/import/preview" }, + { method: "post", path: "/api/companies/import" }, + { method: "get", path: "/api/companies/import/jobs/some-job" }, + { method: "post", path: "/api/companies/import/transfers" }, + { method: "put", path: `/api/companies/import/transfers/${TRANSFER_ID}/parts/0` }, + { method: "get", path: `/api/companies/import/transfers/${TRANSFER_ID}` }, + { method: "post", path: `/api/companies/import/transfers/${TRANSFER_ID}/preview` }, + { method: "post", path: `/api/companies/import/transfers/${TRANSFER_ID}/apply` }, + { method: "post", path: "/api/companies/11111111-2222-4333-8444-555555555555/imports/preview" }, + { method: "post", path: "/api/companies/11111111-2222-4333-8444-555555555555/imports/apply" }, +]; + +async function createApp(actor: Record) { + const [{ companyRoutes }, { errorHandler }] = await Promise.all([ + import("../routes/companies.js"), + import("../middleware/index.js"), + ]); + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = actor; + next(); + }); + app.use("/api/companies", companyRoutes({} as any)); + app.use(errorHandler); + return app; +} + +const boardAdmin = { + type: "board", + source: "local_implicit", + userId: "local-user", + isInstanceAdmin: true, +}; + +function expectNoImportWork() { + expect(mockPortabilityService.previewImport).not.toHaveBeenCalled(); + expect(mockPortabilityService.importBundle).not.toHaveBeenCalled(); + expect(mockTransferRunService.resumeOrCreate).not.toHaveBeenCalled(); + expect(mockTransferRunService.getRunForActor).not.toHaveBeenCalled(); +} + +describe("company import Cloud floor", () => { + beforeEach(() => { + vi.clearAllMocks(); + delete process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN; + delete process.env.PAPERCLIP_MANAGED_CONFIG; + delete process.env.PAPERCLIP_HIDDEN_SETTINGS; + }); + + afterEach(() => { + delete process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN; + delete process.env.PAPERCLIP_MANAGED_CONFIG; + delete process.env.PAPERCLIP_HIDDEN_SETTINGS; + }); + + it("returns 403 cloud_managed on every import route on a cloud-managed instance", async () => { + process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN = "tenant-secret"; + const app = await createApp(boardAdmin); + + for (const route of IMPORT_ROUTES) { + const res = await request(app)[route.method](route.path).send({}); + expect(res.status, `${route.method.toUpperCase()} ${route.path}`).toBe(403); + expect(res.body, `${route.method.toUpperCase()} ${route.path}`).toMatchObject({ + code: "cloud_managed", + }); + } + expectNoImportWork(); + }); + + it("floors on the managed-config signal alone", async () => { + process.env.PAPERCLIP_MANAGED_CONFIG = JSON.stringify({ v: 1, mode: "cloud" }); + const app = await createApp(boardAdmin); + + const res = await request(app).post("/api/companies/import").send({}); + + expect(res.status).toBe(403); + expect(res.body).toMatchObject({ code: "cloud_managed" }); + expectNoImportWork(); + }); + + it("applies the floor before auth and request-body validation", async () => { + process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN = "tenant-secret"; + // An agent actor never passes the import routes' own assertBoard, and the + // body is not schema-valid either — the floor must still answer first so a + // Cloud caller sees one consistent refusal. + const app = await createApp({ type: "agent", agentId: "agent-1", companyId: "company-1" }); + + const res = await request(app) + .post("/api/companies/import/transfers") + .send({ nonsense: true }); + + expect(res.status).toBe(403); + expect(res.body).toMatchObject({ code: "cloud_managed" }); + expectNoImportWork(); + }); + + it("floors every import route with settings_operator_managed when company.import is hidden", async () => { + process.env.PAPERCLIP_HIDDEN_SETTINGS = "company.import"; + const app = await createApp(boardAdmin); + + for (const route of IMPORT_ROUTES) { + const res = await request(app)[route.method](route.path).send({}); + expect(res.status, `${route.method.toUpperCase()} ${route.path}`).toBe(403); + expect(res.body, `${route.method.toUpperCase()} ${route.path}`).toMatchObject({ + code: "settings_operator_managed", + }); + } + expectNoImportWork(); + }); + + it("keeps import open when only other company pages are hidden", async () => { + process.env.PAPERCLIP_HIDDEN_SETTINGS = "company.secrets,company.members"; + const app = await createApp(boardAdmin); + + const res = await request(app).get("/api/companies/import/jobs/unknown-job"); + + expect(res.status).toBe(404); + expect(res.body).toMatchObject({ error: "Import job not found" }); + }); + + it("keeps company export open on a cloud-managed instance", async () => { + process.env.PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN = "tenant-secret"; + mockPortabilityService.exportBundle.mockResolvedValue({ ok: true }); + const app = await createApp(boardAdmin); + + const res = await request(app) + .post("/api/companies/11111111-2222-4333-8444-555555555555/exports") + .send({}); + + expect(res.status).toBe(200); + expect(mockPortabilityService.exportBundle).toHaveBeenCalledTimes(1); + }); + + it("preserves self-hosted import preview", async () => { + mockPortabilityService.previewImport.mockResolvedValue({ companies: [] }); + const app = await createApp(boardAdmin); + + const res = await request(app) + .post("/api/companies/import/preview") + .send({ + source: { type: "github", url: "https://github.com/example/export" }, + target: { mode: "new_company" }, + }); + + expect(res.status).toBe(200); + expect(mockPortabilityService.previewImport).toHaveBeenCalledTimes(1); + }); + + it("preserves self-hosted import job polling", async () => { + const app = await createApp(boardAdmin); + + const res = await request(app).get("/api/companies/import/jobs/unknown-job"); + + expect(res.status).toBe(404); + expect(res.body).toMatchObject({ error: "Import job not found" }); + }); +}); diff --git a/server/src/__tests__/company-portability-routes.test.ts b/server/src/__tests__/company-portability-routes.test.ts index 010d4e2b21..94c57559ee 100644 --- a/server/src/__tests__/company-portability-routes.test.ts +++ b/server/src/__tests__/company-portability-routes.test.ts @@ -859,9 +859,12 @@ describe.sequential("company portability routes", () => { expect(mockLogActivity).not.toHaveBeenCalled(); }); - it.sequential("keeps Cloud-managed global import apply synchronous when async opt-in is absent", async () => { + it.sequential("floors global import apply on a cloud-managed instance, even for the trusted tenant actor", async () => { + // The trusted-tenant tests above run without the cloud env signal on + // purpose: the import floor keys on isCloudManagedInstance(), not on the + // actor. With the signal present, even the trusted tenant actor is + // floored — importing is disabled on cloud-managed instances outright. vi.stubEnv("PAPERCLIP_CLOUD_TENANT_SERVER_TOKEN", "tenant-secret"); - mockCompanyPortabilityService.importBundle.mockResolvedValueOnce(createImportResult("created")); try { const app = await createApp(cloudTenantActor()); @@ -870,15 +873,10 @@ describe.sequential("company portability routes", () => { .set(cloudHeaders) .send(importRequest); - expect(res.status).toBe(200); - expect(res.body.company.id).toBe(companyId); - expect(res.body.company.action).toBe("created"); - expect(res.body.job).toBeUndefined(); - expect(mockCompanyPortabilityService.importBundle).toHaveBeenCalledWith(importRequest, "cloud-user-1", { pauseAutomations: false }); - expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ - action: "company.imported", - companyId, - })); + expect(res.status).toBe(403); + expect(res.body).toMatchObject({ code: "cloud_managed" }); + expect(mockCompanyPortabilityService.importBundle).not.toHaveBeenCalled(); + expect(mockLogActivity).not.toHaveBeenCalled(); } finally { vi.unstubAllEnvs(); } diff --git a/server/src/routes/companies.ts b/server/src/routes/companies.ts index 6a929196e1..a6152c303a 100644 --- a/server/src/routes/companies.ts +++ b/server/src/routes/companies.ts @@ -1,5 +1,5 @@ import { createHash, randomUUID } from "node:crypto"; -import express, { Router, type Request, type Response } from "express"; +import express, { Router, type NextFunction, type Request, type Response } from "express"; import multer from "multer"; import { and, count as countFn, eq } from "drizzle-orm"; import { z } from "zod"; @@ -13,6 +13,7 @@ import { } from "@paperclipai/shared/portability-zip"; import { DEFAULT_FEEDBACK_DATA_SHARING_TERMS_VERSION, + SETTINGS_OPERATOR_MANAGED_ERROR_CODE, companyArtifactsQuerySchema, companyPortabilityExportSchema, companyPortabilityImportSchema, @@ -21,6 +22,7 @@ import { feedbackTargetTypeSchema, feedbackTraceStatusSchema, feedbackVoteValueSchema, + hidesCompanyPage, updateCompanyBrandingSchema, updateCompanySchema, } from "@paperclipai/shared"; @@ -61,6 +63,7 @@ import { workTimelineService, } from "../services/index.js"; import { isCloudManagedInstance } from "../services/cloud-instance.js"; +import { getHiddenSettings } from "../services/settings-visibility.js"; import type { StorageService } from "../storage/types.js"; import { assertBoard, assertCompanyAccess, assertInstanceAdmin, getActorInfo, hasCompanyAccess } from "./authz.js"; import { COMPANY_IMPORT_ROUTE_PATH } from "./company-import-paths.js"; @@ -503,6 +506,40 @@ export function companyRoutes(db: Db, storage?: StorageService, options?: Compan res.json(buildExportFidelityReport(companyId, counts)); }); + /** + * Floor for the whole company-import surface: single-shot upload, preview, + * job polling, chunked transfers, and the per-company agent-safe import + * routes. Two independent signals close it, both checked up front — before + * auth or body work, the same way the company-creation floor does: + * + * - a cloud-managed instance (`cloud_managed`): the hosting platform + * provisions the company, so materializing imported companies on a + * managed instance is disabled unconditionally; + * - the operator hiding the Import page (`company.import` in + * `PAPERCLIP_HIDDEN_SETTINGS` → `settings_operator_managed`): hiding the + * page also disables its API, so the hide is real rather than cosmetic. + * + * Export routes stay open either way — they are the tenant's + * data-portability escape hatch. + */ + const importFloor = (_req: Request, _res: Response, next: NextFunction) => { + if (isCloudManagedInstance()) { + throw forbidden("Company import is disabled on cloud-managed instances", { + code: "cloud_managed", + }); + } + if (hidesCompanyPage(getHiddenSettings(), "company.import")) { + throw forbidden("Company import is hidden by the hosting operator", { + code: SETTINGS_OPERATOR_MANAGED_ERROR_CODE, + }); + } + next(); + }; + // COMPANY_IMPORT_TRANSFERS_ROUTE_PATH nests under the import path, so these + // two prefixes cover every import route registered below. + router.use(COMPANY_IMPORT_ROUTE_PATH, importFloor); + router.use("/:companyId/imports", importFloor); + router.post("/import/preview", async (req, res) => { assertBoard(req); const body = companyPortabilityPreviewSchema.parse(await resolveImportPayload(req, res)); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 161c2b915f..bf877bfa34 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -9,6 +9,7 @@ import { PipelinesExperimentalGate } from "./components/PipelinesExperimentalGat 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 { useHiddenSettings } from "./hooks/useHiddenSettings"; import { Cases } from "./pages/Cases"; @@ -115,19 +116,31 @@ function boardRoutes() { } /> } /> } /> - } /> - } /> - } /> - }> - - - )} - /> - } /> - } /> + }> + } /> + } /> + + }> + } /> + + }> + }> + + + )} + /> + + }> + }> + } /> + + + }> + } /> + } /> } /> } /> diff --git a/ui/src/components/CloudManagedPageGate.test.tsx b/ui/src/components/CloudManagedPageGate.test.tsx new file mode 100644 index 0000000000..c07bc80dc1 --- /dev/null +++ b/ui/src/components/CloudManagedPageGate.test.tsx @@ -0,0 +1,72 @@ +// @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 { queryKeys } from "@/lib/queryKeys"; +import { CloudManagedPageGate } from "./CloudManagedPageGate"; + +vi.mock("@/lib/router", () => ({ + Navigate: ({ to, replace }: { to: string; replace?: boolean }) => ( +
+ ), + Outlet: () =>
Page 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("CloudManagedPageGate", () => { + let container: HTMLDivElement; + let root: Root | null = null; + + async function renderGate(health?: Record) { + root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + if (health !== undefined) { + queryClient.setQueryData(queryKeys.health, health); + } + 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 settings root on a Cloud-managed instance", async () => { + await renderGate({ status: "ok", cloud: { managed: true } }); + + expect(container.querySelector('[data-testid="navigate"]')?.getAttribute("data-to")).toBe( + "/company/settings", + ); + expect(container.querySelector('[data-testid="page-content"]')).toBeNull(); + }); + + it("renders the page on a self-hosted instance", async () => { + await renderGate({ status: "ok" }); + + expect(container.querySelector('[data-testid="page-content"]')).not.toBeNull(); + expect(container.querySelector('[data-testid="navigate"]')).toBeNull(); + }); +}); diff --git a/ui/src/components/CloudManagedPageGate.tsx b/ui/src/components/CloudManagedPageGate.tsx new file mode 100644 index 0000000000..7673965083 --- /dev/null +++ b/ui/src/components/CloudManagedPageGate.tsx @@ -0,0 +1,17 @@ +import { Navigate, Outlet } from "@/lib/router"; +import { useCloudInstance } from "@/hooks/useCloudInstance"; + +/** + * Route gate for pages that are floored on cloud-managed instances (the + * server answers 403 `cloud_managed`), like company import. Cloud-managed + * instances redirect to the settings root instead of rendering a dead-ended + * page. Under + * CloudAccessGate the health response is always cached before board routes + * mount, so the cloud flag is already resolved when this renders. + */ +export function CloudManagedPageGate() { + const isCloud = Boolean(useCloudInstance()); + + if (isCloud) return ; + return ; +} diff --git a/ui/src/components/CompanySettingsSidebar.test.tsx b/ui/src/components/CompanySettingsSidebar.test.tsx index d8bc36c3ef..a0671cb844 100644 --- a/ui/src/components/CompanySettingsSidebar.test.tsx +++ b/ui/src/components/CompanySettingsSidebar.test.tsx @@ -377,12 +377,13 @@ describe("CompanySettingsSidebar operator-hidden entries", () => { vi.clearAllMocks(); }); - async function renderSidebar(hiddenSettings?: string[]) { + async function renderSidebar(hiddenSettings?: string[], cloud?: { managed: boolean }) { const root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); queryClient.setQueryData(queryKeys.health, { status: "ok", ...(hiddenSettings ? { hiddenSettings } : {}), + ...(cloud ? { cloud } : {}), }); await act(async () => { root.render( @@ -412,6 +413,31 @@ describe("CompanySettingsSidebar operator-hidden entries", () => { expect(container.textContent).toContain("Plugins"); expect(container.textContent).toContain("Heartbeats"); expect(container.textContent).toContain("Adapters"); + expect(container.textContent).toContain("Import"); expect(mockPluginsApi.list).toHaveBeenCalled(); }); + + it("hides Import but keeps Export on a Cloud-managed instance", async () => { + await renderSidebar(undefined, { managed: true }); + + expect(container.textContent).not.toContain("Import"); + expect(container.textContent).toContain("Export"); + }); + + it("hides operator-hidden company pages", async () => { + await renderSidebar([ + "company.members", + "company.invites", + "company.secrets", + "company.export", + "company.import", + ]); + + expect(container.textContent).toContain("General"); + expect(container.textContent).not.toContain("Members"); + expect(container.textContent).not.toContain("Invites"); + expect(container.textContent).not.toContain("Secrets"); + expect(container.textContent).not.toContain("Export"); + expect(container.textContent).not.toContain("Import"); + }); }); diff --git a/ui/src/components/CompanySettingsSidebar.tsx b/ui/src/components/CompanySettingsSidebar.tsx index d1c01eeb8b..2f9e7abaaa 100644 --- a/ui/src/components/CompanySettingsSidebar.tsx +++ b/ui/src/components/CompanySettingsSidebar.tsx @@ -25,6 +25,7 @@ import { SIDEBAR_SCROLL_RESET_STATE } from "@/lib/navigation-scroll"; import { queryKeys } from "@/lib/queryKeys"; import { useCompany } from "@/context/CompanyContext"; import { useSidebar } from "@/context/SidebarContext"; +import { useCloudInstance } from "@/hooks/useCloudInstance"; import { useHiddenSettings } from "@/hooks/useHiddenSettings"; import { usePluginSlots } from "@/plugins/slots"; import { SidebarNavItem } from "./SidebarNavItem"; @@ -47,6 +48,9 @@ export function CompanySettingsSidebar() { const { hidden: hiddenSettings } = useHiddenSettings(); const showPage = (pageKey: string) => !hiddenSettings.has(pageKey); const showPlugins = showPage("instance.plugins"); + // Import is floored server-side on cloud-managed instances (403 cloud_managed), so the + // nav entry is hidden rather than dead-ending. Export stays available. + const isCloud = Boolean(useCloudInstance()); const { slots: companySettingsPluginSlots } = usePluginSlots({ slotTypes: ["companySettingsPage"], companyId: selectedCompanyId, @@ -105,13 +109,15 @@ export function CompanySettingsSidebar() { end /> )} - + {showPage("company.members") && ( + + )} {companySettingsPluginSlots .filter((slot) => slot.routePath) .map((slot) => ( @@ -123,8 +129,12 @@ export function CompanySettingsSidebar() { end /> ))} - - + {showPage("company.invites") && ( + + )} + {showPage("company.secrets") && ( + + )} {showPage("instance.environments") && ( )} - - + {showPage("company.export") && ( + + )} + {!isCloud && showPage("company.import") && ( + + )} {showPage("instance.experimental") && ( { expect(getCompanySettingsTab("/company/settings/instance/adapters")).toBe("instance-adapters"); }); - function renderNav(root: ReturnType, hiddenSettings?: string[]) { + function renderNav( + root: ReturnType, + hiddenSettings?: string[], + cloud?: { managed: boolean }, + ) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); queryClient.setQueryData(queryKeys.health, { status: "ok", ...(hiddenSettings ? { hiddenSettings } : {}), + ...(cloud ? { cloud } : {}), }); root.render( @@ -177,4 +182,45 @@ describe("CompanySettingsNav", () => { root.unmount(); }); }); + + it("filters operator-hidden company tabs out of the tab bar", async () => { + currentPathname = "/PAP/company/settings/members"; + const root = createRoot(container); + + await act(async () => { + renderNav(root, ["company.import", "company.secrets"]); + }); + + const renderedValues = pageTabBarMock.mock.calls.at(-1)?.[0]?.items?.map( + (item: { value: string }) => item.value, + ); + expect(renderedValues).not.toContain("import"); + expect(renderedValues).not.toContain("secrets"); + expect(renderedValues).toContain("export"); + expect(renderedValues).toContain("members"); + expect(renderedValues).toContain("invites"); + + await act(async () => { + root.unmount(); + }); + }); + + it("suppresses the Import tab on a Cloud-managed instance", async () => { + currentPathname = "/PAP/company/settings/members"; + const root = createRoot(container); + + await act(async () => { + renderNav(root, undefined, { managed: true }); + }); + + const renderedValues = pageTabBarMock.mock.calls.at(-1)?.[0]?.items?.map( + (item: { value: string }) => item.value, + ); + expect(renderedValues).not.toContain("import"); + expect(renderedValues).toContain("export"); + + await act(async () => { + root.unmount(); + }); + }); }); diff --git a/ui/src/components/access/CompanySettingsNav.tsx b/ui/src/components/access/CompanySettingsNav.tsx index e07147bcdc..96ff1c1b97 100644 --- a/ui/src/components/access/CompanySettingsNav.tsx +++ b/ui/src/components/access/CompanySettingsNav.tsx @@ -1,5 +1,6 @@ import { PageTabBar } from "@/components/PageTabBar"; import { Tabs } from "@/components/ui/tabs"; +import { useCloudInstance } from "@/hooks/useCloudInstance"; import { useHiddenSettings } from "@/hooks/useHiddenSettings"; import { INSTANCE_SETTINGS_PATH_PREFIX } from "@/lib/instance-settings"; import { useLocation, useNavigate } from "@/lib/router"; @@ -24,6 +25,11 @@ type CompanySettingsTab = (typeof items)[number]["value"]; /** Tab values suppressed when their page is operator-hidden. */ const hiddenSettingKeyByTab: Partial> = { + export: "company.export", + import: "company.import", + members: "company.members", + invites: "company.invites", + secrets: "company.secrets", "instance-profile": "instance.profile", "instance-environments": "instance.environments", "instance-access": "instance.access", @@ -97,8 +103,12 @@ export function CompanySettingsNav() { const location = useLocation(); const navigate = useNavigate(); const { hidden: hiddenSettings } = useHiddenSettings(); + // Import is floored server-side on cloud-managed instances (403 cloud_managed), so the + // tab is suppressed there rather than dead-ending. + const isCloud = Boolean(useCloudInstance()); const activeTab = getCompanySettingsTab(location.pathname); const visibleItems = items.filter((item) => { + if (item.value === "import" && isCloud) return false; const hiddenKey = hiddenSettingKeyByTab[item.value]; return !hiddenKey || !hiddenSettings.has(hiddenKey); }); diff --git a/ui/src/pages/OrgChart.test.tsx b/ui/src/pages/OrgChart.test.tsx index e29b0e8927..d76f32ae70 100644 --- a/ui/src/pages/OrgChart.test.tsx +++ b/ui/src/pages/OrgChart.test.tsx @@ -4,6 +4,7 @@ import { act } from "react"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { queryKeys } from "@/lib/queryKeys"; import { OrgChart } from "./OrgChart"; const navigateMock = vi.fn(); @@ -262,4 +263,19 @@ describe("OrgChart mobile gestures", () => { expect(layer.style.transform).toBe("translate(-45px, 40px) scale(1.5)"); }); + + it("shows both portability buttons on self-hosted instances", async () => { + await renderOrgChart(); + + expect(container.textContent).toContain("Import company"); + expect(container.textContent).toContain("Export company"); + }); + + it("hides the Import button but keeps Export on a Cloud-managed instance", async () => { + queryClient.setQueryData(queryKeys.health, { status: "ok", cloud: { managed: true } }); + await renderOrgChart(); + + expect(container.textContent).not.toContain("Import company"); + expect(container.textContent).toContain("Export company"); + }); }); diff --git a/ui/src/pages/OrgChart.tsx b/ui/src/pages/OrgChart.tsx index 8c67809304..d394489b43 100644 --- a/ui/src/pages/OrgChart.tsx +++ b/ui/src/pages/OrgChart.tsx @@ -13,6 +13,8 @@ import { PageSkeleton } from "../components/PageSkeleton"; import { AgentIcon } from "../components/AgentIconPicker"; import { Download, Maximize2, Minus, Network, Plus, Upload } from "lucide-react"; import { AGENT_ROLE_LABELS, type Agent } from "@paperclipai/shared"; +import { useCloudInstance } from "@/hooks/useCloudInstance"; +import { useHiddenSettings } from "@/hooks/useHiddenSettings"; // Layout constants const CARD_W = 200; @@ -175,6 +177,13 @@ export function OrgChart() { const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); const navigate = useNavigate(); + // Import is floored server-side on cloud-managed instances (403 cloud_managed), so the + // button is hidden rather than dead-ending. Export stays available. Both + // buttons also respect the operator-hidden settings registry. + const isCloud = Boolean(useCloudInstance()); + const { hidden: hiddenSettings } = useHiddenSettings(); + const showImport = !isCloud && !hiddenSettings.has("company.import"); + const showExport = !hiddenSettings.has("company.export"); const { data: orgTree, isLoading } = useQuery({ queryKey: queryKeys.org(selectedCompanyId!), @@ -444,18 +453,22 @@ export function OrgChart() { return (
- - - - - - + {showImport && ( + + + + )} + {showExport && ( + + + + )}