feat: hideable company settings pages, with import floored on cloud-managed instances (#12199)

## 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
This commit is contained in:
Devin Foley 2026-08-25 16:06:35 -07:00 committed by GitHub
parent a5c2add7be
commit 9c03443c48
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 589 additions and 54 deletions

View File

@ -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

View File

@ -41,6 +41,15 @@ All environment variables that Paperclip uses for server configuration.
- Any experimental toggle: `instance.experimental.<flagKey>` (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

View File

@ -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.

View File

@ -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,

View File

@ -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<string>(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);

View File

@ -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<string>,
page: HideableCompanyPage,
): boolean {
return hidden.has(page);
}
export function hidesGeneralSection(
hidden: ReadonlySet<string>,
section: HideableGeneralSection,

View File

@ -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<string, unknown>) {
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" });
});
});

View File

@ -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();
}

View File

@ -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));

View File

@ -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() {
<Route path="company/settings" element={<CompanySettings />} />
<Route path="company/settings/environments" element={<Navigate to="/company/settings/instance/environments" replace />} />
<Route path="company/settings/cloud-upstream" element={<Navigate to="/company/export" replace />} />
<Route path="company/settings/members" element={<CompanyAccess />} />
<Route path="company/settings/access" element={<CompanyAccessLegacyRoute />} />
<Route path="company/settings/invites" element={<CompanyInvites />} />
<Route
path="company/export/*"
element={(
<Suspense fallback={<PaperclipLoading />}>
<CompanyExport />
</Suspense>
)}
/>
<Route path="company/import" element={<CompanyImport />} />
<Route path="company/settings/secrets" element={<Secrets />} />
<Route element={<HiddenSettingsPageGate pageKey="company.members" />}>
<Route path="company/settings/members" element={<CompanyAccess />} />
<Route path="company/settings/access" element={<CompanyAccessLegacyRoute />} />
</Route>
<Route element={<HiddenSettingsPageGate pageKey="company.invites" />}>
<Route path="company/settings/invites" element={<CompanyInvites />} />
</Route>
<Route element={<HiddenSettingsPageGate pageKey="company.export" />}>
<Route
path="company/export/*"
element={(
<Suspense fallback={<PaperclipLoading />}>
<CompanyExport />
</Suspense>
)}
/>
</Route>
<Route element={<CloudManagedPageGate />}>
<Route element={<HiddenSettingsPageGate pageKey="company.import" />}>
<Route path="company/import" element={<CompanyImport />} />
</Route>
</Route>
<Route element={<HiddenSettingsPageGate pageKey="company.secrets" />}>
<Route path="company/settings/secrets" element={<Secrets />} />
</Route>
<Route path="company/settings/tools" element={<LegacyToolsSettingsRedirect />} />
<Route path="company/settings/tools/:tab" element={<LegacyToolsSettingsRedirect />} />
<Route path="tools" element={<LegacyToolsRedirect />} />

View File

@ -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 }) => (
<div data-testid="navigate" data-to={to} data-replace={String(replace)} />
),
Outlet: () => <div data-testid="page-content">Page content</div>,
}));
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<string, unknown>) {
root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
if (health !== undefined) {
queryClient.setQueryData(queryKeys.health, health);
}
flushSync(() => {
root!.render(
<QueryClientProvider client={queryClient}>
<CloudManagedPageGate />
</QueryClientProvider>,
);
});
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();
});
});

View File

@ -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 <Navigate to="/company/settings" replace />;
return <Outlet />;
}

View File

@ -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");
});
});

View File

@ -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
/>
)}
<SidebarNavItem
to="/company/settings/members"
label="Members"
icon={Users}
badge={badges?.joinRequests ?? 0}
end
/>
{showPage("company.members") && (
<SidebarNavItem
to="/company/settings/members"
label="Members"
icon={Users}
badge={badges?.joinRequests ?? 0}
end
/>
)}
{companySettingsPluginSlots
.filter((slot) => slot.routePath)
.map((slot) => (
@ -123,8 +129,12 @@ export function CompanySettingsSidebar() {
end
/>
))}
<SidebarNavItem to="/company/settings/invites" label="Invites" icon={MailPlus} end />
<SidebarNavItem to="/company/settings/secrets" label="Secrets" icon={KeyRound} end />
{showPage("company.invites") && (
<SidebarNavItem to="/company/settings/invites" label="Invites" icon={MailPlus} end />
)}
{showPage("company.secrets") && (
<SidebarNavItem to="/company/settings/secrets" label="Secrets" icon={KeyRound} end />
)}
{showPage("instance.environments") && (
<SidebarNavItem
to={`${INSTANCE_SETTINGS_PATH_PREFIX}/environments`}
@ -149,8 +159,12 @@ export function CompanySettingsSidebar() {
end
/>
)}
<SidebarNavItem to="/company/export" label="Export" icon={Download} />
<SidebarNavItem to="/company/import" label="Import" icon={Upload} end />
{showPage("company.export") && (
<SidebarNavItem to="/company/export" label="Export" icon={Download} />
)}
{!isCloud && showPage("company.import") && (
<SidebarNavItem to="/company/import" label="Import" icon={Upload} end />
)}
{showPage("instance.experimental") && (
<SidebarNavItem
to={`${INSTANCE_SETTINGS_PATH_PREFIX}/experimental`}

View File

@ -91,11 +91,16 @@ describe("CompanySettingsNav", () => {
expect(getCompanySettingsTab("/company/settings/instance/adapters")).toBe("instance-adapters");
});
function renderNav(root: ReturnType<typeof createRoot>, hiddenSettings?: string[]) {
function renderNav(
root: ReturnType<typeof createRoot>,
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(
<QueryClientProvider client={queryClient}>
@ -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();
});
});
});

View File

@ -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<Record<CompanySettingsTab, string>> = {
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);
});

View File

@ -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");
});
});

View File

@ -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 (
<div className="flex h-(--sz-calc-38) min-h-(--sz-420px) flex-col md:h-full md:min-h-0">
<div className="mb-2 flex shrink-0 flex-wrap items-center justify-start gap-2">
<Link to="/company/import">
<Button variant="outline" size="sm">
<Upload className="mr-1.5 h-3.5 w-3.5" />
Import company
</Button>
</Link>
<Link to="/company/export">
<Button variant="outline" size="sm">
<Download className="mr-1.5 h-3.5 w-3.5" />
Export company
</Button>
</Link>
{showImport && (
<Link to="/company/import">
<Button variant="outline" size="sm">
<Upload className="mr-1.5 h-3.5 w-3.5" />
Import company
</Button>
</Link>
)}
{showExport && (
<Link to="/company/export">
<Button variant="outline" size="sm">
<Download className="mr-1.5 h-3.5 w-3.5" />
Export company
</Button>
</Link>
)}
</div>
<div
ref={containerRef}