feat: operator-configurable settings visibility via PAPERCLIP_HIDDEN_SETTINGS (#11823)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - The instance settings surface (Access, Plugins, Adapters, General,
Experimental) assumes the person at the keyboard operates the whole
instance
> - Operators who host Paperclip for others — a managed cloud or an
internal shared server — expose settings pages and toggles that do not
apply to their deployment, and the related mutation APIs stay open
> - A hosted tenant can open Plugins or Adapters, try an action, and hit
a confusing failure, because only a few hardcoded platform floors exist
> - This pull request adds a generic, operator-configured visibility
mechanism: one env var hides declared settings surfaces in the UI and
floors their mutation routes with a stable 403 code
> - The benefit is a clean hosted-tenant settings surface for any
operator, with zero behavior change for normal self-hosted instances

## Linked Issues or Issue Description

**Subsystem affected**

Instance settings (server routes and UI), the shared settings registry
in `packages/shared`, and the `/api/health` bootstrap payload.

**Problem or motivation**

An operator who hosts Paperclip for other people cannot hide settings
surfaces that the platform manages. Tenants see Access, Plugins, and
Adapters pages, backup retention, and host-level experimental toggles
that do nothing useful for them. The mutation APIs behind these surfaces
also stay open, so a tenant admin can attempt actions the platform must
control. ROADMAP.md names a cleaner shared deployment story as a goal
("Teams should be able to run the same product in hosted or semi-hosted
environments without changing the mental model").

**Proposed solution**

Add a declarative registry of hideable settings surfaces and one env
var, `PAPERCLIP_HIDDEN_SETTINGS`. The server parses the list at boot,
reports it on `/api/health`, and rejects value-changing writes to hidden
surfaces with a stable `settings_operator_managed` 403 code. The UI
reads the list from the health payload and removes the hidden pages,
sections, and toggles from navigation, routes, and page content. Unknown
keys warn and are ignored, so one list can roll across a fleet with
mixed app versions. With the variable unset, behavior is byte-identical
to today.

**Alternatives considered**

- Hardcode the hidden set for cloud instances in this repo: rejected,
because each hosting operator needs a different policy, and policy does
not belong in shared code.
- Deliver the hidden set through the managed-config document: rejected,
because that channel is cloud-specific and fail-closed on unknown
fields; a plain env var works for any operator, including self-hosted
shared servers.
- Lock the controls with a badge instead of hiding them: rejected for
these surfaces, because they are meaningless to tenants, not merely
platform-controlled; the existing managed-overlay lock stays the right
tool for controlled flags.

**Roadmap alignment**

Supports the "shared deployment story" item in ROADMAP.md: hosted and
semi-hosted deployments keep the same product with a settings surface
that matches what the tenant can actually do.

## What Changed

- New `packages/shared/src/settings-visibility.ts`: registry of hideable
surfaces (every instance settings page — profile, environments, access,
heartbeats, experimental, plugins, adapters; every Instance → General
section; every experimental flag as `instance.experimental.<key>`), the
`PAPERCLIP_HIDDEN_SETTINGS` parser, and the `settings_operator_managed`
error code. The General page stays visible as the settings root and
redirect target.
- New `server/src/services/settings-visibility.ts`: parse-once accessor;
unknown keys log one warning and are ignored.
- `/api/health` reports `hiddenSettings` on every response shape; the
field is omitted when nothing is hidden.
- Server floors on hidden surfaces, with same-value echo tolerance (the
`executionMode` precedent): field-backed general sections and
experimental keys reject value-changing PATCHes, and hiding the whole
Experimental page floors every toggle; plugin lifecycle and config
writes, adapter management writes, and the Access admin routes (reads
included) return 403 `settings_operator_managed`. Reads the app itself
needs (plugin `ui-contributions`, adapter metadata, plugin job trigger)
stay open. Pages without instance-scoped mutation routes are hidden in
the UI only.
- UI: new `useHiddenSettings` hook and `HiddenSettingsPageGate` route
gate (hidden pages redirect to the settings root); the settings sidebar
and tab bar drop hidden entries; remembered settings paths remap to the
default page; `InstanceGeneralSettings` skips hidden sections; every
`ExperimentalToggleCard` now carries its flag key and renders nothing
when hidden.
- Removed the dead `InstanceSidebar` component (referenced only by its
own test).
- Docs: `docs/deploy/environment-variables.md` documents the variable
and the key registry.

## Verification

- `pnpm vitest run` over the new and extended suites: shared registry
and parser, representative floor tests per route class (changed-value
403, same-value echo 200, unset env 200, page-level Experimental
hiding), the health field, the route gate, nav filtering, and
section/card hiding with one hidden example per surface kind — 168 tests
pass.
- Full root `pnpm typecheck` passes.
- Manual: booted a server with the variable set. `/api/health` lists the
keys; an unknown key logs one warning and the server boots; hidden pages
redirect; hidden sections and cards do not render; hidden-field PATCH
returns 403 with `details.code = "settings_operator_managed"`; a
same-value echo returns 200. Unset the variable: the full settings
surface returns and responses are byte-identical to master.

## Risks

- Low risk for self-hosted instances: with the variable unset, the
hidden set is empty, the health field is omitted, and no floor
activates.
- Flooring plugin config writes assumes hosted deployments configure
plugins through the platform. If a future bundled plugin needs
tenant-entered config, the floor needs a narrow carve-out.
- Hidden-key floors tolerate same-value echoes, so API clients that
round-trip full GET responses keep working.
- Hiding a toggle does not change its value; operators pair hiding with
the desired default where the value matters.

## Model Used

Claude Fable 5 (Anthropic, `claude-fable-5`) with extended thinking and
agentic tool use, driven through the Claude Code CLI (file edits, test
execution, and live-server verification loops).

## 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 authored and GitHub committed 2026-08-20 17:54:44 -07:00
1 parent 0fa318b8da
commit db4defdfbf
32 files changed
+1328 -414

No files matched your search

@@ -0,0 +1,95 @@
import express from "express";
import request from "supertest";
import { afterEach, beforeAll, describe, expect, it } from "vitest";
/**
* Operator-hidden Access surface floor (`instance.access` in
* PAPERCLIP_HIDDEN_SETTINGS): instance-admin user management routes reject
* with a stable code while the rest of the access router stays untouched.
* The floor throws before any data access, so a stub db suffices; the
* unfloored happy paths are covered by the embedded-postgres access tests.
*/
const stubDb = {
select: () => ({
from: () => {
const chain = {
orderBy: async () => [] as unknown[],
where: async () => [] as unknown[],
};
return chain;
},
}),
} as never;
async function createApp() {
const [{ accessRoutes }, { errorHandler }] = await Promise.all([
import("../routes/access.js"),
import("../middleware/index.js"),
]);
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.actor = {
type: "board",
userId: "instance-admin-1",
source: "local_implicit",
companyIds: ["company-1"],
memberships: [{ companyId: "company-1", membershipRole: "owner", status: "active" }],
isInstanceAdmin: true,
} as Express.Request["actor"];
next();
});
app.use("/api", accessRoutes(stubDb, {
deploymentMode: "authenticated",
deploymentExposure: "private",
bindHost: "127.0.0.1",
allowedHostnames: [],
}));
app.use(errorHandler);
return app;
}
describe("operator-hidden access admin floor", () => {
let app: express.Express;
// The access router is a large module; import and build the app once so the
// cost is not charged to the first test's timeout on slow CI runners.
beforeAll(async () => {
app = await createApp();
}, 30_000);
afterEach(() => {
delete process.env.PAPERCLIP_HIDDEN_SETTINGS;
});
const attempts: Array<[string, () => request.Test]> = [
["promote", () => request(app).post("/api/admin/users/user-1/promote-instance-admin")],
["demote", () => request(app).post("/api/admin/users/user-1/demote-instance-admin")],
[
"company access write",
() => request(app).put("/api/admin/users/user-1/company-access").send({ companyIds: [] }),
],
["user listing", () => request(app).get("/api/admin/users")],
["company access read", () => request(app).get("/api/admin/users/user-1/company-access")],
];
it.each(attempts)(
"floors the %s route when the operator hides the surface",
async (_name, buildRequest) => {
process.env.PAPERCLIP_HIDDEN_SETTINGS = "instance.access";
const res = await buildRequest();
expect(res.status, JSON.stringify(res.body)).toBe(403);
expect(res.body.details).toMatchObject({ code: "settings_operator_managed" });
},
);
it("keeps the routes reachable when the surface is not hidden", async () => {
const res = await request(app).get("/api/admin/users");
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(res.body).toEqual([]);
});
});
@@ -367,4 +367,41 @@ describe.sequential("adapter management route authorization", () => {
},
);
});
describe("operator-hidden adapter management floor", () => {
beforeEach(() => {
process.env.PAPERCLIP_HIDDEN_SETTINGS = "instance.adapters";
});
afterEach(() => {
delete process.env.PAPERCLIP_HIDDEN_SETTINGS;
});
it.each(["install", "disable", "override", "delete", "reload", "reinstall"] as const)(
"floors adapter %s for instance admins when the operator hides the Adapters surface",
async (routeName) => {
resetInstalledExternalAdapterState();
if (routeName !== "install") {
seedInstalledExternalAdapter();
}
const app = createApp(instanceAdmin);
const res = await sendMutatingRequest(app, routeName);
expect(res.status, `${routeName}: ${JSON.stringify(res.body)}`).toBe(403);
expect(res.body.details).toMatchObject({ code: "settings_operator_managed" });
expect(mocks.execFile).not.toHaveBeenCalled();
expect(mocks.loadExternalAdapterPackage).not.toHaveBeenCalled();
expect(mocks.reloadExternalAdapter).not.toHaveBeenCalled();
},
);
it("keeps adapter reads open while the surface is hidden", async () => {
seedInstalledExternalAdapter();
const app = createApp(boardMember("admin"));
const res = await requestApp(app, (baseUrl) => request(baseUrl).get("/api/adapters"));
expect(res.status, JSON.stringify(res.body)).toBe(200);
});
});
});
+20
View File
@@ -116,6 +116,26 @@ describe("GET /health", () => {
});
});
it("lists operator-hidden settings and drops unknown keys", async () => {
const app = createApp(undefined, testServerInfo, undefined, {
PAPERCLIP_HIDDEN_SETTINGS: "instance.plugins,instance.adapters,instance.bogus",
});
const res = await request(app).get("/health");
expect(res.status).toBe(200);
expect(res.body.hiddenSettings).toEqual(["instance.plugins", "instance.adapters"]);
});
it("omits hiddenSettings entirely when nothing is hidden", async () => {
const app = createApp(undefined, testServerInfo, undefined, {});
const res = await request(app).get("/health");
expect(res.status).toBe(200);
expect(Object.prototype.hasOwnProperty.call(res.body, "hiddenSettings")).toBe(false);
});
it("returns 200 when the database probe succeeds", async () => {
const db = {
execute: vi.fn().mockResolvedValue([{ "?column?": 1 }]),
@@ -805,4 +805,123 @@ describe("instance settings routes", () => {
expect(mockInstanceSettingsService.updateGeneral).toHaveBeenCalledWith({ executionMode: "kubernetes" });
});
});
describe("operator-hidden settings floor", () => {
const adminActor = {
type: "board",
userId: "admin-1",
source: "session",
isInstanceAdmin: true,
companyIds: ["company-1"],
};
afterEach(() => {
delete process.env.PAPERCLIP_HIDDEN_SETTINGS;
});
it("rejects a write that changes a hidden general field", async () => {
process.env.PAPERCLIP_HIDDEN_SETTINGS = "instance.general.censorUsernameInLogs";
const app = await createApp(adminActor);
const res = await request(app)
.patch("/api/instance/settings/general")
.send({ censorUsernameInLogs: true });
expect(res.status).toBe(403);
expect(res.body.details).toMatchObject({ code: "settings_operator_managed" });
expect(mockInstanceSettingsService.updateGeneral).not.toHaveBeenCalled();
});
it("allows a same-value echo of a hidden general field", async () => {
process.env.PAPERCLIP_HIDDEN_SETTINGS = "instance.general.censorUsernameInLogs";
const app = await createApp(adminActor);
const res = await request(app)
.patch("/api/instance/settings/general")
.send({ censorUsernameInLogs: false, keyboardShortcuts: true });
expect(res.status).toBe(200);
expect(mockInstanceSettingsService.updateGeneral).toHaveBeenCalledWith({
censorUsernameInLogs: false,
keyboardShortcuts: true,
});
});
it("deep-compares hidden backupRetention echoes instead of rejecting them", async () => {
process.env.PAPERCLIP_HIDDEN_SETTINGS = "instance.general.backupRetention";
mockInstanceSettingsService.getGeneral.mockResolvedValue({
censorUsernameInLogs: false,
keyboardShortcuts: false,
feedbackDataSharingPreference: "prompt",
backupRetention: { dailyDays: 7, weeklyWeeks: 4, monthlyMonths: 1 },
});
const app = await createApp(adminActor);
const echo = await request(app)
.patch("/api/instance/settings/general")
.send({ backupRetention: { dailyDays: 7, weeklyWeeks: 4, monthlyMonths: 1 } });
expect(echo.status).toBe(200);
const change = await request(app)
.patch("/api/instance/settings/general")
.send({ backupRetention: { dailyDays: 14, weeklyWeeks: 4, monthlyMonths: 1 } });
expect(change.status).toBe(403);
expect(change.body.details).toMatchObject({ code: "settings_operator_managed" });
});
it("rejects a write that changes a hidden experimental toggle", async () => {
process.env.PAPERCLIP_HIDDEN_SETTINGS = "instance.experimental.enableEnvironments";
const app = await createApp(adminActor);
const res = await request(app)
.patch("/api/instance/settings/experimental")
.send({ enableEnvironments: true });
expect(res.status).toBe(403);
expect(res.body.details).toMatchObject({ code: "settings_operator_managed" });
expect(mockInstanceSettingsService.updateExperimental).not.toHaveBeenCalled();
});
it("allows writes to non-hidden experimental toggles while others are hidden", async () => {
process.env.PAPERCLIP_HIDDEN_SETTINGS =
"instance.experimental.enableEnvironments,instance.experimental.enableServerInfoDebugView";
const app = await createApp(adminActor);
const res = await request(app)
.patch("/api/instance/settings/experimental")
.send({ enableIsolatedWorkspaces: true });
expect(res.status).toBe(200);
expect(mockInstanceSettingsService.updateExperimental).toHaveBeenCalledWith({
enableIsolatedWorkspaces: true,
});
});
it("floors every experimental toggle when the whole Experimental page is hidden", async () => {
process.env.PAPERCLIP_HIDDEN_SETTINGS = "instance.experimental";
const app = await createApp(adminActor);
const res = await request(app)
.patch("/api/instance/settings/experimental")
.send({ enableIsolatedWorkspaces: true });
expect(res.status).toBe(403);
expect(res.body.details).toMatchObject({ code: "settings_operator_managed" });
expect(mockInstanceSettingsService.updateExperimental).not.toHaveBeenCalled();
});
it("keeps every field writable when the env var is unset", async () => {
const app = await createApp(adminActor);
const general = await request(app)
.patch("/api/instance/settings/general")
.send({ censorUsernameInLogs: true });
expect(general.status).toBe(200);
const experimental = await request(app)
.patch("/api/instance/settings/experimental")
.send({ enableEnvironments: true });
expect(experimental.status).toBe(200);
});
});
});
@@ -1,6 +1,6 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const mockRegistry = vi.hoisted(() => ({
getById: vi.fn(),
@@ -1103,3 +1103,55 @@ describe.sequential("plugin tool and bridge authz", () => {
expect(executeTool).not.toHaveBeenCalled();
});
});
describe.sequential("operator-hidden plugin management floor", () => {
beforeEach(() => {
vi.clearAllMocks();
process.env.PAPERCLIP_HIDDEN_SETTINGS = "instance.plugins";
});
afterEach(() => {
delete process.env.PAPERCLIP_HIDDEN_SETTINGS;
});
const instanceAdmin = () => boardActor({ isInstanceAdmin: true, userId: "instance-admin" });
it("floors plugin lifecycle and config writes for instance admins", async () => {
const { app, loader } = await createApp(instanceAdmin());
readyPlugin();
const attempts: Array<[string, request.Test]> = [
["install", request(app).post("/api/plugins/install").send({ packageName: "@paperclipai/plugin-modal" })],
["uninstall", request(app).delete(`/api/plugins/${pluginId}`)],
["enable", request(app).post(`/api/plugins/${pluginId}/enable`)],
["disable", request(app).post(`/api/plugins/${pluginId}/disable`).send({})],
["upgrade", request(app).post(`/api/plugins/${pluginId}/upgrade`).send({})],
["config", request(app).post(`/api/plugins/${pluginId}/config`).send({ config: {} })],
["config test", request(app).post(`/api/plugins/${pluginId}/config/test`).send({ config: {} })],
[
"local folder",
request(app)
.put(`/api/plugins/${pluginId}/companies/${companyA}/local-folders/data`)
.send({ path: "/tmp/folder" }),
],
];
for (const [name, attempt] of attempts) {
const res = await attempt;
expect(res.status, `${name}: ${JSON.stringify(res.body)}`).toBe(403);
expect(res.body.details, name).toMatchObject({ code: "settings_operator_managed" });
}
expect(loader.installPlugin).not.toHaveBeenCalled();
expect(mockLifecycle.enable).not.toHaveBeenCalled();
expect(mockLifecycle.disable).not.toHaveBeenCalled();
expect(mockLifecycle.upgrade).not.toHaveBeenCalled();
expect(mockLifecycle.unload).not.toHaveBeenCalled();
expect(mockRegistry.upsertConfig).not.toHaveBeenCalled();
});
it("keeps plugin reads open while the surface is hidden", async () => {
const { app } = await createApp(boardActor());
const res = await request(app).get("/api/plugins/examples");
expect(res.status, JSON.stringify(res.body)).toBe(200);
}, 20_000);
});
+21
View File
@@ -56,6 +56,22 @@ import {
badRequest,
tooManyRequests
} from "../errors.js";
import { getHiddenSettings } from "../services/settings-visibility.js";
/**
* Floor: when the hosting operator hides the Instance Access surface
* (`instance.access` in PAPERCLIP_HIDDEN_SETTINGS), instance-admin user
* management is rejected alongside it — user administration then belongs to
* the operator's own control plane. Applies to the Access page's reads too;
* invite and company-membership routes are company-scoped and stay open.
*/
function assertAccessAdminVisible() {
if (getHiddenSettings().has("instance.access")) {
throw forbidden("Instance user administration is managed by the hosting operator on this instance", {
code: "settings_operator_managed",
});
}
}
import {
createInviteRateLimiter,
type InviteRateLimiter,
@@ -4774,6 +4790,7 @@ export function accessRoutes(
"/admin/users/:userId/promote-instance-admin",
async (req, res) => {
await assertInstanceAdmin(req);
assertAccessAdminVisible();
const userId = req.params.userId as string;
const result = await access.promoteInstanceAdmin(userId);
res.status(201).json(result);
@@ -4782,6 +4799,7 @@ export function accessRoutes(
router.get("/admin/users", async (req, res) => {
await assertInstanceAdmin(req);
assertAccessAdminVisible();
const query = searchAdminUsersQuerySchema.parse(req.query);
const needle = query.query.trim().toLowerCase();
const users = await db
@@ -4844,6 +4862,7 @@ export function accessRoutes(
"/admin/users/:userId/demote-instance-admin",
async (req, res) => {
await assertInstanceAdmin(req);
assertAccessAdminVisible();
const userId = req.params.userId as string;
const removed = await access.demoteInstanceAdmin(userId);
if (!removed) throw notFound("Instance admin role not found");
@@ -4853,6 +4872,7 @@ export function accessRoutes(
router.get("/admin/users/:userId/company-access", async (req, res) => {
await assertInstanceAdmin(req);
assertAccessAdminVisible();
const userId = req.params.userId as string;
res.json(await loadUserCompanyAccessResponse(db, access, userId));
});
@@ -4862,6 +4882,7 @@ export function accessRoutes(
validate(updateUserCompanyAccessSchema),
async (req, res) => {
await assertInstanceAdmin(req);
assertAccessAdminVisible();
const userId = req.params.userId as string;
await access.setUserCompanyAccess(
userId,
+23
View File
@@ -50,6 +50,7 @@ import { loadExternalAdapterPackage, getUiParserSource, getOrExtractUiParserSour
import { logger } from "../middleware/logger.js";
import { forbidden } from "../errors.js";
import { isCloudManagedInstance } from "../services/cloud-instance.js";
import { getHiddenSettings } from "../services/settings-visibility.js";
import { assertBoardOrgAccess, assertInstanceAdmin } from "./authz.js";
import { BUILTIN_ADAPTER_TYPES } from "../adapters/builtin-adapter-types.js";
@@ -71,6 +72,20 @@ function assertAdapterCodeInstallAllowed() {
}
}
/**
* Floor: when the hosting operator hides the Adapters settings surface
* (`instance.adapters` in PAPERCLIP_HIDDEN_SETTINGS), adapter management
* writes are rejected alongside it. Reads stay open — adapter metadata is
* consumed by agent-creation UIs outside the hidden page.
*/
function assertAdapterManagementVisible() {
if (getHiddenSettings().has("instance.adapters")) {
throw forbidden("Adapter management is managed by the hosting operator on this instance", {
code: "settings_operator_managed",
});
}
}
// ---------------------------------------------------------------------------
// Request / Response types
// ---------------------------------------------------------------------------
@@ -288,6 +303,7 @@ export function adapterRoutes() {
router.post("/adapters/install", async (req, res) => {
assertInstanceAdmin(req);
assertAdapterCodeInstallAllowed();
assertAdapterManagementVisible();
const { packageName, isLocalPath = false, version } = req.body as AdapterInstallRequest;
@@ -435,6 +451,8 @@ export function adapterRoutes() {
router.patch("/adapters/:type", async (req, res) => {
assertInstanceAdmin(req);
assertAdapterManagementVisible();
const adapterType = req.params.type;
const { disabled } = req.body as { disabled?: boolean };
@@ -470,6 +488,8 @@ export function adapterRoutes() {
router.patch("/adapters/:type/override", async (req, res) => {
assertInstanceAdmin(req);
assertAdapterManagementVisible();
const adapterType = req.params.type;
const { paused } = req.body as { paused?: boolean };
@@ -497,6 +517,7 @@ export function adapterRoutes() {
*/
router.delete("/adapters/:type", async (req, res) => {
assertInstanceAdmin(req);
assertAdapterManagementVisible();
const adapterType = req.params.type;
@@ -573,6 +594,7 @@ export function adapterRoutes() {
*/
router.post("/adapters/:type/reload", async (req, res) => {
assertInstanceAdmin(req);
assertAdapterManagementVisible();
const type = req.params.type;
@@ -626,6 +648,7 @@ export function adapterRoutes() {
router.post("/adapters/:type/reinstall", async (req, res) => {
assertInstanceAdmin(req);
assertAdapterCodeInstallAllowed();
assertAdapterManagementVisible();
const type = req.params.type;
+10
View File
@@ -12,6 +12,7 @@ import {
isCloudManagedInstance,
type CloudInstanceEnv,
} from "../services/cloud-instance.js";
import { getHiddenSettings } from "../services/settings-visibility.js";
import {
inspectDatabaseBackupHealth,
type DatabaseBackupHealthStatus,
@@ -157,6 +158,11 @@ export function healthRoutes(
);
const runtimeEnv = opts.runtimeEnv ?? process.env;
const cloud = getCloudHealthStatus(runtimeEnv);
// Operator-hidden settings ride every response (like `cloud`): the list
// holds UI surface names only, and the settings nav needs it before any
// fuller-detail fetch. Omitted entirely when nothing is hidden, so
// deployments without the env var keep today's byte-identical responses.
const hiddenSettings = [...getHiddenSettings(runtimeEnv)];
// serverInfo (git SHA + process start) rides on the full-details responses
// only, so it reaches board/agent actors in authenticated mode or any caller
// in local_trusted dev — never anonymous authenticated callers. The
@@ -192,12 +198,14 @@ export function healthRoutes(
commit,
serverInfo,
...(cloud ? { cloud } : {}),
...(hiddenSettings.length ? { hiddenSettings } : {}),
}
: {
status: "ok",
deploymentMode: opts.deploymentMode,
commit,
...(cloud ? { cloud } : {}),
...(hiddenSettings.length ? { hiddenSettings } : {}),
},
);
return;
@@ -307,6 +315,7 @@ export function healthRoutes(
// this instance becomes visible.
...(workspaceReadiness ? { workspace: workspaceReadiness } : {}),
...(cloud ? { cloud } : {}),
...(hiddenSettings.length ? { hiddenSettings } : {}),
});
return;
}
@@ -330,6 +339,7 @@ export function healthRoutes(
...(devServer ? { devServer } : {}),
...(workspaceReadiness ? { workspace: workspaceReadiness } : {}),
...(cloud ? { cloud } : {}),
...(hiddenSettings.length ? { hiddenSettings } : {}),
});
});
+55
View File
@@ -8,12 +8,52 @@ import {
} from "@paperclipai/shared";
import { forbidden } from "../errors.js";
import { isCloudManagedInstance } from "../services/cloud-instance.js";
import { getHiddenSettings } from "../services/settings-visibility.js";
import { validate } from "../middleware/validate.js";
import { heartbeatService, instanceSettingsService, logActivity } from "../services/index.js";
import { environmentService } from "../services/environments.js";
import { assertEnvironmentSelectionForCompany } from "./environment-selection.js";
import { assertBoardOrgAccess, getActorInfo } from "./authz.js";
function sameJsonValue(a: unknown, b: unknown): boolean {
if (a === b) return true;
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
if (Array.isArray(a) || Array.isArray(b)) {
return (
Array.isArray(a)
&& Array.isArray(b)
&& a.length === b.length
&& a.every((value, i) => sameJsonValue(value, b[i]))
);
}
const aKeys = Object.keys(a);
const bKeys = new Set(Object.keys(b));
return aKeys.length === bKeys.size && aKeys.every((key) =>
bKeys.has(key) && sameJsonValue((a as Record<string, unknown>)[key], (b as Record<string, unknown>)[key]),
);
}
/**
* Floor writes to operator-hidden settings. Same-value writes pass so clients
* that echo a full GET response keep working (the executionMode precedent);
* only a write that would actually change a hidden setting is rejected.
*/
async function assertNoHiddenSettingChanges(
body: Record<string, unknown>,
getCurrent: () => Promise<object>,
isHiddenField: (field: string) => boolean,
) {
const hiddenKeys = Object.keys(body).filter(isHiddenField);
if (hiddenKeys.length === 0) return;
const current = (await getCurrent()) as Record<string, unknown>;
for (const key of hiddenKeys) {
if (sameJsonValue(body[key], current[key])) continue;
throw forbidden(`${key} is managed by the hosting operator on this instance`, {
code: "settings_operator_managed",
});
}
}
function assertCanManageInstanceSettings(req: Request) {
if (req.actor.type !== "board") {
throw forbidden("Board access required");
@@ -122,6 +162,12 @@ export function instanceSettingsRoutes(db: Db) {
});
}
}
const hidden = getHiddenSettings();
await assertNoHiddenSettingChanges(
req.body,
() => svc.getGeneral(),
(field) => hidden.has(`instance.general.${field}`),
);
const updated = await svc.updateGeneral(req.body);
const actor = getActorInfo(req);
const companyIds = await svc.listCompanyIds();
@@ -161,6 +207,15 @@ export function instanceSettingsRoutes(db: Db) {
validate(patchInstanceExperimentalSettingsSchema),
async (req, res) => {
assertCanManageInstanceSettings(req);
// Hiding the whole Experimental page floors every toggle; otherwise
// only individually hidden keys are floored.
const hidden = getHiddenSettings();
await assertNoHiddenSettingChanges(
req.body,
() => svc.getExperimental(),
(field) =>
hidden.has("instance.experimental") || hidden.has(`instance.experimental.${field}`),
);
const updated = await svc.updateExperimental(req.body);
const actor = getActorInfo(req);
const companyIds = await svc.listCompanyIds();
+23
View File
@@ -89,9 +89,24 @@ import {
isWithinBundledPluginRoot,
} from "../services/plugin-install-guard.js";
import { isCloudManagedInstance } from "../services/cloud-instance.js";
import { getHiddenSettings } from "../services/settings-visibility.js";
import { secretService } from "../services/secrets.js";
import { badRequest, forbidden, notFound, unauthorized, unprocessable } from "../errors.js";
/**
* Floor: when the hosting operator hides the Plugins settings surface
* (`instance.plugins` in PAPERCLIP_HIDDEN_SETTINGS), plugin lifecycle and
* configuration writes are rejected alongside it. Reads stay open — installed
* plugins keep running and `/plugins/ui-contributions` still powers their UI.
*/
function assertPluginManagementVisible() {
if (getHiddenSettings().has("instance.plugins")) {
throw forbidden("Plugin management is managed by the hosting operator on this instance", {
code: "settings_operator_managed",
});
}
}
/** UI slot declaration extracted from plugin manifest */
type PluginUiSlotDeclaration = NonNullable<NonNullable<PaperclipPluginManifestV1["ui"]>["slots"]>[number];
/** Launcher declaration extracted from plugin manifest */
@@ -1125,6 +1140,7 @@ export function pluginRoutes(
*/
router.post("/plugins/install", async (req, res) => {
assertInstanceAdmin(req);
assertPluginManagementVisible();
const { packageName, version, isLocalPath } = req.body as PluginInstallRequest;
// Input validation
@@ -1968,6 +1984,7 @@ export function pluginRoutes(
*/
router.delete("/plugins/:pluginId", async (req, res) => {
assertInstanceAdmin(req);
assertPluginManagementVisible();
const { pluginId } = req.params;
const purge = req.query.purge === "true";
@@ -2004,6 +2021,7 @@ export function pluginRoutes(
*/
router.post("/plugins/:pluginId/enable", async (req, res) => {
assertInstanceAdmin(req);
assertPluginManagementVisible();
const { pluginId } = req.params;
const plugin = await resolvePlugin(registry, pluginId);
@@ -2042,6 +2060,7 @@ export function pluginRoutes(
*/
router.post("/plugins/:pluginId/disable", async (req, res) => {
assertInstanceAdmin(req);
assertPluginManagementVisible();
const { pluginId } = req.params;
const body = req.body as { reason?: string } | undefined;
const reason = body?.reason;
@@ -2204,6 +2223,7 @@ export function pluginRoutes(
*/
router.post("/plugins/:pluginId/upgrade", async (req, res) => {
assertInstanceAdmin(req);
assertPluginManagementVisible();
const { pluginId } = req.params;
const body = req.body as { version?: string } | undefined;
const version = body?.version;
@@ -2285,6 +2305,7 @@ export function pluginRoutes(
*/
router.post("/plugins/:pluginId/config", async (req, res) => {
assertInstanceAdmin(req);
assertPluginManagementVisible();
const { pluginId } = req.params;
const plugin = await resolvePlugin(registry, pluginId);
@@ -2417,6 +2438,7 @@ export function pluginRoutes(
*/
router.post("/plugins/:pluginId/config/test", async (req, res) => {
assertBoardOrgAccess(req);
assertPluginManagementVisible();
if (!bridgeDeps) {
res.status(501).json({ error: "Plugin bridge is not enabled" });
@@ -2893,6 +2915,7 @@ export function pluginRoutes(
router.put("/plugins/:pluginId/companies/:companyId/local-folders/:folderKey", async (req, res) => {
assertBoardOrgAccess(req);
assertPluginManagementVisible();
const { pluginId, companyId, folderKey } = req.params;
assertCompanyAccess(req, companyId);
@@ -0,0 +1,37 @@
import { parseHiddenSettingsList } from "@paperclipai/shared";
import { logger } from "../middleware/logger.js";
/**
* Operator-hidden settings, from the `PAPERCLIP_HIDDEN_SETTINGS` env var
* (comma-separated keys from the shared settings-visibility registry). Unknown
* keys are warned about once and ignored so one list can be rolled across a
* fleet of mixed app versions without refusing boot on older images.
*/
export const HIDDEN_SETTINGS_ENV_KEY = "PAPERCLIP_HIDDEN_SETTINGS";
export type HiddenSettingsEnv = Record<string, string | undefined>;
let cache: { raw: string | undefined; hidden: ReadonlySet<string> } | null = null;
/**
* Parse-once accessor keyed on the raw env value. Callers that pass a custom
* env (tests) get a fresh parse whenever the raw value differs; process.env
* callers share one parsed set for the process lifetime. Members are always
* `HideableSettingKey`s; typed as strings so route code can probe with
* computed `instance.*` keys.
*/
export function getHiddenSettings(
env: HiddenSettingsEnv = process.env,
): ReadonlySet<string> {
const raw = env[HIDDEN_SETTINGS_ENV_KEY];
if (cache && cache.raw === raw) return cache.hidden;
const { hidden, unknown } = parseHiddenSettingsList(raw);
if (unknown.length > 0) {
logger.warn(
{ unknownKeys: unknown },
`${HIDDEN_SETTINGS_ENV_KEY} contains unknown keys; they are ignored`,
);
}
cache = { raw, hidden: new Set(hidden) };
return cache.hidden;
}