From 89358f38bf5a631a530b06268c8763cc6ffdf99e Mon Sep 17 00:00:00 2001 From: Dotta Date: Thu, 20 Aug 2026 19:16:50 +0000 Subject: [PATCH] feat(plugins): enforce server-authoritative EE host features Co-Authored-By: Paperclip --- packages/shared/src/constants.ts | 20 ++++ packages/shared/src/index.ts | 8 ++ packages/shared/src/types/index.ts | 2 + packages/shared/src/types/plugin.ts | 19 ++++ packages/shared/src/validators/index.ts | 2 + packages/shared/src/validators/plugin.test.ts | 50 ++++++++++ packages/shared/src/validators/plugin.ts | 31 ++++++ server/src/__tests__/openapi-routes.test.ts | 1 + .../__tests__/runtime-features-html.test.ts | 20 ++++ .../__tests__/runtime-features-route.test.ts | 48 +++++++++ .../src/__tests__/static-index-html.test.ts | 19 +++- .../src/__tests__/tool-access-service.test.ts | 97 ++++++++++++++++++- server/src/__tests__/tool-gateway.test.ts | 79 ++++++++++++++- .../src/__tests__/vite-html-renderer.test.ts | 29 ++++++ server/src/app.ts | 42 ++++---- server/src/routes/openapi.ts | 14 +++ server/src/routes/runtime-features.ts | 15 +++ server/src/routes/tool-access.ts | 31 ++++-- server/src/routes/tool-gateway.ts | 44 ++++++--- server/src/runtime-features-html.ts | 26 +++++ server/src/services/host-features.test.ts | 57 +++++++++++ server/src/services/host-features.ts | 71 ++++++++++++++ server/src/static-index-html.ts | 10 +- server/src/vite-html-renderer.ts | 4 +- 24 files changed, 690 insertions(+), 49 deletions(-) create mode 100644 server/src/__tests__/runtime-features-html.test.ts create mode 100644 server/src/__tests__/runtime-features-route.test.ts create mode 100644 server/src/routes/runtime-features.ts create mode 100644 server/src/runtime-features-html.ts create mode 100644 server/src/services/host-features.test.ts create mode 100644 server/src/services/host-features.ts diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 935cf0e5106..be499002846 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -1266,6 +1266,26 @@ export type ToolAccessActivityAction = (typeof TOOL_ACCESS_ACTIVITY_ACTIONS)[num */ export const PLUGIN_API_VERSION = 1 as const; +/** + * The only plugin identity allowed to declare Paperclip EE host features. + * Host features expose dormant core product surfaces; they are not plugin + * capabilities and never grant the plugin access to host APIs. + */ +export const PAPERCLIP_EE_PLUGIN_ID = "paperclipai.paperclip-ee" as const; + +/** Current schema version for the closed host-feature manifest declaration. */ +export const HOST_FEATURE_DECLARATION_SCHEMA_VERSION = 1 as const; + +/** + * Host product surfaces that a ready canonical Paperclip EE plugin can enable. + * Keep this list closed so a misspelled or unsupported key fails installation. + */ +export const HOST_FEATURE_KEYS = [ + "apps.named_mcp_gateways", + "apps.access_profiles", +] as const; +export type HostFeatureKey = (typeof HOST_FEATURE_KEYS)[number]; + /** * Lifecycle statuses for an installed plugin. * diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index dd55ba4d91d..d86744d8b85 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -439,6 +439,9 @@ export { TOOL_RUNTIME_SLOT_STATUSES, TOOL_ACCESS_ACTIVITY_ACTIONS, PLUGIN_API_VERSION, + PAPERCLIP_EE_PLUGIN_ID, + HOST_FEATURE_DECLARATION_SCHEMA_VERSION, + HOST_FEATURE_KEYS, PLUGIN_STATUSES, PLUGIN_CATEGORIES, PLUGIN_CAPABILITIES, @@ -468,6 +471,7 @@ export { PLUGIN_EVENT_TYPES, PLUGIN_BRIDGE_ERROR_CODES, type CompanyStatus, + type HostFeatureKey, type DeploymentMode, type DeploymentExposure, type BindMode, @@ -1431,6 +1435,8 @@ export type { PluginLauncherRenderContextSnapshot, PluginLauncherDeclaration, PluginMinimumHostVersion, + PluginHostFeaturesDeclarationV1, + HostFeaturesSnapshotV1, PluginUiDeclaration, PluginDatabaseDeclaration, PluginApiRouteCompanyResolution, @@ -2278,6 +2284,7 @@ export { pluginLauncherDeclarationSchema, pluginDatabaseDeclarationSchema, pluginApiRouteDeclarationSchema, + pluginHostFeaturesDeclarationV1Schema, pluginManifestV1Schema, installPluginSchema, upsertPluginConfigSchema, @@ -2298,6 +2305,7 @@ export { type PluginLauncherDeclarationInput, type PluginDatabaseDeclarationInput, type PluginApiRouteDeclarationInput, + type PluginHostFeaturesDeclarationV1Input, type PluginManifestV1Input, type InstallPlugin, type UpsertPluginConfig, diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 2f63fd280d8..eb30529372e 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -975,6 +975,8 @@ export type { PluginLauncherRenderContextSnapshot, PluginLauncherDeclaration, PluginMinimumHostVersion, + PluginHostFeaturesDeclarationV1, + HostFeaturesSnapshotV1, PluginUiDeclaration, PluginDatabaseDeclaration, PluginApiRouteCompanyResolution, diff --git a/packages/shared/src/types/plugin.ts b/packages/shared/src/types/plugin.ts index 30ff21d289f..8d411de5a3d 100644 --- a/packages/shared/src/types/plugin.ts +++ b/packages/shared/src/types/plugin.ts @@ -27,6 +27,7 @@ import type { RoutineConcurrencyPolicy, RoutineStatus, IssueSurfaceVisibility, + HostFeatureKey, } from "../constants.js"; import type { Agent } from "./agent.js"; import type { CompanySkill } from "./company-skill.js"; @@ -64,6 +65,7 @@ export type { PluginDatabaseMigrationStatus, PluginDatabaseNamespaceMode, PluginDatabaseNamespaceStatus, + HostFeatureKey, } from "../constants.js"; // --------------------------------------------------------------------------- @@ -645,6 +647,21 @@ export interface PluginObjectReferenceProviderDeclaration { // Plugin Manifest V1 // --------------------------------------------------------------------------- +/** + * Versioned declaration of dormant core product surfaces supplied by a host + * feature provider. Version 1 is intentionally closed to known keys. + */ +export interface PluginHostFeaturesDeclarationV1 { + schemaVersion: 1; + features: HostFeatureKey[]; +} + +/** Non-sensitive, instance-level feature snapshot returned to the board UI. */ +export interface HostFeaturesSnapshotV1 { + schemaVersion: 1; + hostFeatures: HostFeatureKey[]; +} + /** * The manifest shape every plugin package must export. * See PLUGIN_SPEC.md §10.1 for the normative definition. @@ -676,6 +693,8 @@ export interface PaperclipPluginManifestV1 { minimumPaperclipVersion?: PluginMinimumHostVersion; /** Capabilities this plugin requires from the host. Enforced at runtime. */ capabilities: PluginCapability[]; + /** Dormant core product surfaces enabled while this provider is ready. */ + hostFeatures?: PluginHostFeaturesDeclarationV1; /** Entrypoint paths relative to the package root. */ entrypoints: { /** Path to the worker entrypoint (required). */ diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 2fc001d5a40..21f03885cb4 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -775,6 +775,7 @@ export { pluginManagedSkillFileDeclarationSchema, pluginManagedSkillDeclarationSchema, pluginApiRouteDeclarationSchema, + pluginHostFeaturesDeclarationV1Schema, pluginManifestV1Schema, installPluginSchema, upsertPluginConfigSchema, @@ -797,6 +798,7 @@ export { type PluginManagedSkillFileDeclarationInput, type PluginManagedSkillDeclarationInput, type PluginApiRouteDeclarationInput, + type PluginHostFeaturesDeclarationV1Input, type PluginManifestV1Input, type InstallPlugin, type UpsertPluginConfig, diff --git a/packages/shared/src/validators/plugin.test.ts b/packages/shared/src/validators/plugin.test.ts index ff2e7a99353..1b4e0992660 100644 --- a/packages/shared/src/validators/plugin.test.ts +++ b/packages/shared/src/validators/plugin.test.ts @@ -33,6 +33,56 @@ describe("plugin capability constants", () => { }); describe("plugin manifest validators", () => { + const canonicalEeManifest = { + id: "paperclipai.paperclip-ee", + apiVersion: 1, + version: "0.1.0", + displayName: "Paperclip EE", + description: "Enterprise availability provider.", + author: "Paperclip", + categories: ["ui"], + capabilities: ["ui.page.register"], + entrypoints: { worker: "./dist/worker.js" }, + hostFeatures: { + schemaVersion: 1, + features: ["apps.named_mcp_gateways", "apps.access_profiles"], + }, + } as const; + + it("accepts the canonical Paperclip EE host-feature declaration", () => { + const parsed = pluginManifestV1Schema.parse(canonicalEeManifest); + + expect(parsed.hostFeatures).toEqual(canonicalEeManifest.hostFeatures); + }); + + it("rejects unknown and duplicate host-feature keys", () => { + const unknown = pluginManifestV1Schema.safeParse({ + ...canonicalEeManifest, + hostFeatures: { schemaVersion: 1, features: ["apps.unknown"] }, + }); + const duplicate = pluginManifestV1Schema.safeParse({ + ...canonicalEeManifest, + hostFeatures: { + schemaVersion: 1, + features: ["apps.access_profiles", "apps.access_profiles"], + }, + }); + + expect(unknown.success).toBe(false); + expect(duplicate.success).toBe(false); + }); + + it("rejects host features from any non-canonical plugin", () => { + const parsed = pluginManifestV1Schema.safeParse({ + ...canonicalEeManifest, + id: "acme.claimed-ee", + }); + + expect(parsed.success).toBe(false); + if (parsed.success) return; + expect(parsed.error.issues).toContainEqual(expect.objectContaining({ path: ["hostFeatures"] })); + }); + it("accepts existing-style plugins that do not request access or authorization capabilities", () => { const parsed = pluginManifestV1Schema.parse({ id: "paperclip.compat-dashboard", diff --git a/packages/shared/src/validators/plugin.ts b/packages/shared/src/validators/plugin.ts index 24a6ecfce3a..6a57e24b0ca 100644 --- a/packages/shared/src/validators/plugin.ts +++ b/packages/shared/src/validators/plugin.ts @@ -1,5 +1,7 @@ import { z } from "zod"; import { + HOST_FEATURE_KEYS, + PAPERCLIP_EE_PLUGIN_ID, PLUGIN_STATUSES, PLUGIN_CATEGORIES, PLUGIN_CAPABILITIES, @@ -721,6 +723,26 @@ export type PluginObjectReferenceProviderDeclarationInput = z.infer< // Plugin Manifest V1 schema // --------------------------------------------------------------------------- +export const pluginHostFeaturesDeclarationV1Schema = z.object({ + schemaVersion: z.literal(1), + features: z.array(z.enum(HOST_FEATURE_KEYS)).min(1), +}).strict().superRefine((declaration, ctx) => { + const duplicateFeatures = declaration.features.filter( + (feature, index) => declaration.features.indexOf(feature) !== index, + ); + if (duplicateFeatures.length > 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Duplicate host features: ${[...new Set(duplicateFeatures)].join(", ")}`, + path: ["features"], + }); + } +}); + +export type PluginHostFeaturesDeclarationV1Input = z.infer< + typeof pluginHostFeaturesDeclarationV1Schema +>; + /** * Zod schema for {@link PaperclipPluginManifestV1} — the complete runtime * validator for plugin manifests read at install time. @@ -780,6 +802,7 @@ export const pluginManifestV1Schema = z.object({ "minimumPaperclipVersion must follow semver (e.g. 1.0.0)", ).optional(), capabilities: z.array(z.enum(PLUGIN_CAPABILITIES)).min(1), + hostFeatures: pluginHostFeaturesDeclarationV1Schema.optional(), entrypoints: z.object({ worker: z.string().min(1), ui: z.string().min(1).optional(), @@ -803,6 +826,14 @@ export const pluginManifestV1Schema = z.object({ launchers: z.array(pluginLauncherDeclarationSchema).optional(), }).optional(), }).superRefine((manifest, ctx) => { + if (manifest.hostFeatures && manifest.id !== PAPERCLIP_EE_PLUGIN_ID) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Host features may only be declared by '${PAPERCLIP_EE_PLUGIN_ID}'`, + path: ["hostFeatures"], + }); + } + // ── Entrypoint ↔ UI slot consistency ────────────────────────────────── // Plugins that declare UI slots must also declare a UI entrypoint so the // host knows where to load the bundle from (PLUGIN_SPEC.md §10.1). diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts index 3d8c85a5f0b..cf69287ceeb 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -50,6 +50,7 @@ const apiPrefixes: Record = { "plugins.ts": "/api", "projects.ts": "/api", "resource-memberships.ts": "/api", + "runtime-features.ts": "/api", "routines.ts": "/api", "secrets.ts": "/api", "sidebar-badges.ts": "/api", diff --git a/server/src/__tests__/runtime-features-html.test.ts b/server/src/__tests__/runtime-features-html.test.ts new file mode 100644 index 00000000000..980406dbddd --- /dev/null +++ b/server/src/__tests__/runtime-features-html.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { injectRuntimeFeaturesHtml, serializeRuntimeFeaturesForHtml } from "../runtime-features-html.js"; + +describe("runtime feature HTML bootstrap", () => { + it("escapes JSON for an inert application/json script", () => { + const serialized = serializeRuntimeFeaturesForHtml({ + schemaVersion: 1, + hostFeatures: ["apps.access_profiles"], + }); + expect(serialized).not.toContain("<"); + expect(serialized).toContain('"apps.access_profiles"'); + + const html = injectRuntimeFeaturesHtml( + "", + { schemaVersion: 1, hostFeatures: [] }, + ); + expect(html).toContain('', + "utf8", + ); + + let enabled = false; + const renderer = createCachedViteHtmlRenderer({ + vite: { watcher: createWatcher() }, + uiRoot: tempDir, + injectHtml: (html) => injectRuntimeFeaturesHtml(html, { + schemaVersion: 1, + hostFeatures: enabled ? ["apps.named_mcp_gateways"] : [], + }), + }); + + const disabledHtml = await renderer.render("/"); + enabled = true; + const enabledHtml = await renderer.render("/"); + + expect(disabledHtml).toContain('"hostFeatures":[]'); + expect(enabledHtml).toContain('"hostFeatures":["apps.named_mcp_gateways"]'); + expect(enabledHtml.match(/paperclip-runtime-features/g)?.length).toBe(1); + }); }); diff --git a/server/src/app.ts b/server/src/app.ts index 3731338c656..b6a4ca10d47 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -1,4 +1,4 @@ -import express, { Router, type Request as ExpressRequest } from "express"; +import express, { Router, type Request as ExpressRequest, type RequestHandler } from "express"; import { createServer as createHttpServer, type Server as HttpServer } from "node:http"; import path from "node:path"; import fs from "node:fs"; @@ -70,6 +70,7 @@ import { sidebarPreferenceRoutes } from "./routes/sidebar-preferences.js"; import { resourceMembershipRoutes } from "./routes/resource-memberships.js"; import { inboxDismissalRoutes } from "./routes/inbox-dismissals.js"; import { instanceSettingsRoutes } from "./routes/instance-settings.js"; +import { runtimeFeatureRoutes } from "./routes/runtime-features.js"; import { openApiRoutes } from "./routes/openapi.js"; import { instanceDatabaseBackupRoutes, @@ -109,6 +110,8 @@ import { pluginRegistryService } from "./services/plugin-registry.js"; import { createHostClientHandlers } from "@paperclipai/plugin-sdk"; import type { BetterAuthSessionResult } from "./auth/better-auth.js"; import { createCachedViteHtmlRenderer } from "./vite-html-renderer.js"; +import { resolveHostFeatures } from "./services/host-features.js"; +import { injectRuntimeFeaturesHtml } from "./runtime-features-html.js"; import { DEFAULT_JSON_BODY_LIMIT, PORTABLE_JSON_BODY_LIMIT } from "./http/body-limits.js"; import { COMPANY_IMPORT_API_PATH } from "./routes/company-import-paths.js"; import { apiCompression } from "./middleware/api-compression.js"; @@ -539,6 +542,7 @@ export async function createApp( api.use(resourceMembershipRoutes(db)); api.use(inboxDismissalRoutes(db)); api.use(instanceSettingsRoutes(db)); + api.use(runtimeFeatureRoutes(db)); if (opts.databaseBackupService) { api.use(instanceDatabaseBackupRoutes(opts.databaseBackupService)); } @@ -571,7 +575,7 @@ export async function createApp( pluginWorkerManager: workerManager, approveToolActionRequest: (input) => toolGateway.approveActionRequest(input), })); - app.use(mcpGatewayProtocolRoutes(toolGateway)); + app.use(mcpGatewayProtocolRoutes(db, toolGateway)); api.use(toolAccessRoutes(db, { deploymentMode: opts.deploymentMode, deploymentExposure: opts.deploymentExposure, @@ -671,6 +675,17 @@ export async function createApp( ]; const uiDist = candidates.find((p) => fs.existsSync(path.join(p, "index.html"))); if (uiDist) { + const serveStaticIndex: RequestHandler = async (_req, res, next) => { + try { + res + .status(200) + .set("Content-Type", "text/html") + .set("Cache-Control", "no-cache") + .end(readBrandedStaticIndexHtml(uiDist, await resolveHostFeatures(db))); + } catch (error) { + next(error); + } + }; // Hashed asset files (Vite emits them under /assets/..) // never change once built, so they can be cached aggressively. app.use( @@ -680,19 +695,15 @@ export async function createApp( immutable: true, }), ); + // Always render index.html through the host-feature injector. Mount this + // before express.static so an explicit /index.html cannot bypass it. + app.get(["/", "/index.html"], serveStaticIndex); // Non-hashed static files (favicon.ico, manifest, robots.txt, etc.): - // short cache so operators who swap them out see the new version - // reasonably fast. Override for `index.html` specifically — it is - // served by this middleware for `/` and `/index.html`, and it must - // never outlive the asset hashes it points at. + // short cache so operators who swap them out see the new version. app.use( express.static(uiDist, { + index: false, maxAge: "1h", - setHeaders(res, filePath) { - if (path.basename(filePath) === "index.html") { - res.set("Cache-Control", "no-cache"); - } - }, }), ); // SPA fallback. Only for non-asset routes — if the browser asks for @@ -701,16 +712,12 @@ export async function createApp( // with a MIME-type error, and cache that broken response. Return 404 // instead. The index.html response itself is no-cache so a subsequent // deploy's updated asset hashes are picked up on next load. - app.get(/.*/, (req, res) => { + app.get(/.*/, async (req, res, next) => { if (req.path.startsWith("/assets/")) { res.status(404).end(); return; } - res - .status(200) - .set("Content-Type", "text/html") - .set("Cache-Control", "no-cache") - .end(readBrandedStaticIndexHtml(uiDist)); + await serveStaticIndex(req, res, next); }); } else { console.warn("[paperclip] UI dist not found; running in API-only mode"); @@ -759,6 +766,7 @@ export async function createApp( vite, uiRoot, brandHtml: applyUiBranding, + injectHtml: async (html) => injectRuntimeFeaturesHtml(html, await resolveHostFeatures(db)), }); const renderViteHtml = viteHtmlRenderer; diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 690c4883e33..c068e451436 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -6865,6 +6865,20 @@ registerCurrentRoute({ body: issueGraphLivenessAutoRecoveryRequestSchema, }); +registerCurrentRoute({ + method: "get", + path: "/api/runtime-features", + tags: ["runtime"], + summary: "Get the effective host feature snapshot", + responses: { + 200: r.ok(z.object({ + schemaVersion: z.literal(1), + hostFeatures: z.array(z.enum(["apps.named_mcp_gateways", "apps.access_profiles"])), + }).strict()), + 401: r.unauthorized, + }, +}); + registerCurrentRoute({ method: "get", path: "/api/issues/{id}/accepted-plan-decompositions", diff --git a/server/src/routes/runtime-features.ts b/server/src/routes/runtime-features.ts new file mode 100644 index 00000000000..aa249786c18 --- /dev/null +++ b/server/src/routes/runtime-features.ts @@ -0,0 +1,15 @@ +import { Router } from "express"; +import type { Db } from "@paperclipai/db"; +import { resolveHostFeatures } from "../services/host-features.js"; +import { assertAuthenticated } from "./authz.js"; + +export function runtimeFeatureRoutes(db: Db) { + const router = Router(); + + router.get("/runtime-features", async (req, res) => { + assertAuthenticated(req); + res.json(await resolveHostFeatures(db)); + }); + + return router; +} diff --git a/server/src/routes/tool-access.ts b/server/src/routes/tool-access.ts index 0c4bef458e2..7e4b2cca789 100644 --- a/server/src/routes/tool-access.ts +++ b/server/src/routes/tool-access.ts @@ -1,4 +1,4 @@ -import { Router, type Request } from "express"; +import { Router, type Request, type RequestHandler } from "express"; import type { Db } from "@paperclipai/db"; import { agents, companies } from "@paperclipai/db"; import { eq } from "drizzle-orm"; @@ -45,6 +45,7 @@ import { getActorInfo, assertBoard, assertCompanyAccess, getAccessibleResource, import { badRequest, forbidden, unprocessable } from "../errors.js"; import { accessService, googleSheetsRobotEmailFromEnv, logActivity, toolAccessPolicyService, toolAccessService } from "../services/index.js"; import { ToolGatewayHttpError, type ToolGatewayService } from "../services/tool-gateway.js"; +import { requireHostFeature } from "../services/host-features.js"; /** Allowlist (e.g. Google Sheets allowed spreadsheet ids) lives in connection config. */ function allowlistIds(config: Record | null | undefined): string[] { @@ -114,6 +115,14 @@ export function toolAccessRoutes( return new URL("/api/tools/oauth/callback", configured).toString(); } const access = accessService(db); + const profileAdministrationGate: RequestHandler = async (_req, _res, next) => { + try { + await requireHostFeature(db, "apps.access_profiles"); + next(); + } catch (error) { + next(error); + } + }; async function assertBoardToolPermission(req: Request, companyId: string, permissionKey: PermissionKey) { assertBoard(req); @@ -879,14 +888,14 @@ export function toolAccessRoutes( res.json({ profiles: await svc.listProfiles(companyId) }); }); - router.get("/tool-profiles/:profileId/new-tools", async (req, res) => { + router.get("/tool-profiles/:profileId/new-tools", profileAdministrationGate, async (req, res) => { assertBoard(req); const existing = await getAccessibleResource(req, res, svc.getProfile(req.params.profileId as string), "Tool profile not found"); if (!existing) return; res.json(await svc.listProfileNewTools(existing.id, existing.companyId)); }); - router.post("/companies/:companyId/tools/profiles", validate(createToolProfileWithEntriesSchema), async (req, res) => { + router.post("/companies/:companyId/tools/profiles", profileAdministrationGate, validate(createToolProfileWithEntriesSchema), async (req, res) => { const companyId = req.params.companyId as string; assertToolAppMutationAccess(req, companyId); try { @@ -913,7 +922,7 @@ export function toolAccessRoutes( res.json(await svc.getEffectiveProfilesForAgent(companyId, req.params.agentId as string)); }); - router.patch("/tool-profiles/:profileId", validate(updateToolProfileWithEntriesSchema), async (req, res) => { + router.patch("/tool-profiles/:profileId", profileAdministrationGate, validate(updateToolProfileWithEntriesSchema), async (req, res) => { const existing = await getAccessibleResource(req, res, svc.getProfile(req.params.profileId as string), "Tool profile not found"); if (!existing) return; assertToolAppMutationAccess(req, existing.companyId); @@ -934,7 +943,7 @@ export function toolAccessRoutes( } }); - router.post("/tool-profiles/:profileId/duplicate", validate(duplicateToolProfileSchema), async (req, res) => { + router.post("/tool-profiles/:profileId/duplicate", profileAdministrationGate, validate(duplicateToolProfileSchema), async (req, res) => { const existing = await getAccessibleResource(req, res, svc.getProfile(req.params.profileId as string), "Tool profile not found"); if (!existing) return; assertToolAppMutationAccess(req, existing.companyId); @@ -960,7 +969,7 @@ export function toolAccessRoutes( } }); - router.delete("/tool-profiles/:profileId", validate(deleteToolProfileSchema), async (req, res) => { + router.delete("/tool-profiles/:profileId", profileAdministrationGate, validate(deleteToolProfileSchema), async (req, res) => { const existing = await getAccessibleResource(req, res, svc.getProfile(req.params.profileId as string), "Tool profile not found"); if (!existing) return; assertToolAppMutationAccess(req, existing.companyId); @@ -982,7 +991,7 @@ export function toolAccessRoutes( res.json(result); }); - router.post("/tool-profiles/:profileId/new-tools/review", validate(reviewToolProfileNewToolsSchema), async (req, res) => { + router.post("/tool-profiles/:profileId/new-tools/review", profileAdministrationGate, validate(reviewToolProfileNewToolsSchema), async (req, res) => { const existing = await getAccessibleResource(req, res, svc.getProfile(req.params.profileId as string), "Tool profile not found"); if (!existing) return; assertToolAppMutationAccess(req, existing.companyId); @@ -1003,7 +1012,7 @@ export function toolAccessRoutes( res.json(result); }); - router.post("/tool-profiles/:profileId/entries", validate(createToolProfileEntryForProfileSchema), async (req, res) => { + router.post("/tool-profiles/:profileId/entries", profileAdministrationGate, validate(createToolProfileEntryForProfileSchema), async (req, res) => { const existing = await getAccessibleResource(req, res, svc.getProfile(req.params.profileId as string), "Tool profile not found"); if (!existing) return; assertToolAppMutationAccess(req, existing.companyId); @@ -1020,7 +1029,7 @@ export function toolAccessRoutes( res.status(201).json(entry); }); - router.patch("/tool-profile-entries/:entryId", validate(updateToolProfileEntrySchema), async (req, res) => { + router.patch("/tool-profile-entries/:entryId", profileAdministrationGate, validate(updateToolProfileEntrySchema), async (req, res) => { const existing = await getAccessibleResource(req, res, svc.getProfileEntry(req.params.entryId as string), "Tool profile entry not found"); if (!existing) return; assertToolAppMutationAccess(req, existing.companyId); @@ -1037,7 +1046,7 @@ export function toolAccessRoutes( res.json(entry); }); - router.delete("/tool-profile-entries/:entryId", async (req, res) => { + router.delete("/tool-profile-entries/:entryId", profileAdministrationGate, async (req, res) => { const existing = await getAccessibleResource(req, res, svc.getProfileEntry(req.params.entryId as string), "Tool profile entry not found"); if (!existing) return; assertToolAppMutationAccess(req, existing.companyId); @@ -1056,6 +1065,7 @@ export function toolAccessRoutes( router.post( "/companies/:companyId/tools/profiles/:profileId/bind", + profileAdministrationGate, validate(createToolProfileBindingForProfileSchema), async (req, res) => { const companyId = req.params.companyId as string; @@ -1081,6 +1091,7 @@ export function toolAccessRoutes( router.post( "/companies/:companyId/tools/profiles/:profileId/unbind", + profileAdministrationGate, validate(unbindToolProfileBindingSchema), async (req, res) => { const companyId = req.params.companyId as string; diff --git a/server/src/routes/tool-gateway.ts b/server/src/routes/tool-gateway.ts index 76a91c54a3f..8352dca5aca 100644 --- a/server/src/routes/tool-gateway.ts +++ b/server/src/routes/tool-gateway.ts @@ -12,6 +12,7 @@ import { assertBoard, assertBoardOrAgent, assertCompanyAccess, getActorInfo } fr import { ToolGatewayHttpError, type ToolGatewayService } from "../services/tool-gateway.js"; import { forbidden, HttpError } from "../errors.js"; import { accessService } from "../services/index.js"; +import { requireHostFeature } from "../services/host-features.js"; const TOOL_GATEWAY_ACTIONS = [ "tool_gateway.session_created", @@ -58,10 +59,12 @@ function callerHeaders(req: { headers: Record { - res.json({ - transport: "streamable_http", - endpoint: `/mcp/gateways/${req.params.gatewayPublicId}`, - authentication: "bearer", - }); + try { + await requireHostFeature(db, "apps.named_mcp_gateways"); + res.json({ + transport: "streamable_http", + endpoint: `/mcp/gateways/${req.params.gatewayPublicId}`, + authentication: "bearer", + }); + } catch (err) { + sendGatewayError(res, err); + } }); router.post("/mcp/gateways/:gatewayPublicId", async (req, res) => { - await handleMcpGatewayProtocol(req, res, toolGateway, { gatewayPublicId: req.params.gatewayPublicId }); + await handleMcpGatewayProtocol(req, res, db, toolGateway, { gatewayPublicId: req.params.gatewayPublicId }); }); return router; } @@ -283,6 +291,7 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) { router.get("/companies/:companyId/tools/gateways", async (req, res) => { try { + await requireHostFeature(db, "apps.named_mcp_gateways"); await assertBoardPermission(req, req.params.companyId, "tools:admin"); res.json({ gateways: await toolGateway.listNamedGateways(req.params.companyId) }); } catch (err) { @@ -292,6 +301,7 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) { router.post("/companies/:companyId/tools/gateways", async (req, res) => { try { + await requireHostFeature(db, "apps.named_mcp_gateways"); await assertBoardPermission(req, req.params.companyId, "tools:admin"); const parsed = createToolMcpGatewaySchema.safeParse(req.body ?? {}); if (!parsed.success) { @@ -312,6 +322,7 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) { router.patch("/tool-gateway/gateways/:gatewayId", async (req, res) => { try { + await requireHostFeature(db, "apps.named_mcp_gateways"); assertBoard(req); const companyId = typeof req.body?.companyId === "string" ? req.body.companyId : null; if (!companyId) { @@ -333,6 +344,7 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) { router.post("/tool-gateway/gateways/:gatewayId/tokens", async (req, res) => { try { + await requireHostFeature(db, "apps.named_mcp_gateways"); assertBoard(req); const companyId = typeof req.body?.companyId === "string" ? req.body.companyId : null; if (!companyId) { @@ -359,6 +371,7 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) { router.post("/tool-gateway/gateway-tokens/:tokenId/revoke", async (req, res) => { try { + await requireHostFeature(db, "apps.named_mcp_gateways"); assertBoard(req); const companyId = typeof req.body?.companyId === "string" ? req.body.companyId : null; if (!companyId) { @@ -373,15 +386,20 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) { }); router.get("/tool-gateway/gateways/:gatewayId/mcp", async (req, res) => { - res.json({ - transport: "streamable_http", - endpoint: `/api/tool-gateway/gateways/${req.params.gatewayId}/mcp`, - authentication: "bearer", - }); + try { + await requireHostFeature(db, "apps.named_mcp_gateways"); + res.json({ + transport: "streamable_http", + endpoint: `/api/tool-gateway/gateways/${req.params.gatewayId}/mcp`, + authentication: "bearer", + }); + } catch (err) { + sendGatewayError(res, err); + } }); router.post("/tool-gateway/gateways/:gatewayId/mcp", async (req, res) => { - await handleMcpGatewayProtocol(req, res, toolGateway, { gatewayId: req.params.gatewayId }); + await handleMcpGatewayProtocol(req, res, db, toolGateway, { gatewayId: req.params.gatewayId }); }); router.post("/tool-gateway/sessions", async (req, res) => { diff --git a/server/src/runtime-features-html.ts b/server/src/runtime-features-html.ts new file mode 100644 index 00000000000..de7e9005860 --- /dev/null +++ b/server/src/runtime-features-html.ts @@ -0,0 +1,26 @@ +import type { HostFeaturesSnapshotV1 } from "@paperclipai/shared"; + +export const RUNTIME_FEATURES_HTML_MARKER = ""; + +/** Escape JSON so it cannot terminate its application/json script element. */ +export function serializeRuntimeFeaturesForHtml(snapshot: HostFeaturesSnapshotV1): string { + return JSON.stringify(snapshot) + .replace(/&/g, "\\u0026") + .replace(//g, "\\u003e") + .replace(/\u2028/g, "\\u2028") + .replace(/\u2029/g, "\\u2029"); +} + +export function injectRuntimeFeaturesHtml( + html: string, + snapshot: HostFeaturesSnapshotV1, +): string { + const script = ``; + if (html.includes(RUNTIME_FEATURES_HTML_MARKER)) { + return html.replace(RUNTIME_FEATURES_HTML_MARKER, script); + } + return html.includes("") + ? html.replace("", ` ${script}\n`) + : `${script}\n${html}`; +} diff --git a/server/src/services/host-features.test.ts b/server/src/services/host-features.test.ts new file mode 100644 index 00000000000..bb8569e6f72 --- /dev/null +++ b/server/src/services/host-features.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import type { PaperclipPluginManifestV1, PluginStatus } from "@paperclipai/shared"; +import { resolveHostFeaturesFromProvider } from "./host-features.js"; + +const manifest: PaperclipPluginManifestV1 = { + id: "paperclipai.paperclip-ee", + apiVersion: 1, + version: "0.1.0", + displayName: "Paperclip EE", + description: "Enterprise availability provider.", + author: "Paperclip", + categories: ["ui"], + capabilities: ["ui.page.register"], + entrypoints: { worker: "./dist/worker.js" }, + hostFeatures: { + schemaVersion: 1, + features: ["apps.named_mcp_gateways", "apps.access_profiles"], + }, +}; + +describe("host feature lifecycle resolver", () => { + it("enables declared features only while canonical EE is ready", () => { + expect(resolveHostFeaturesFromProvider({ + pluginKey: manifest.id, + status: "ready", + manifestJson: manifest, + }).hostFeatures).toEqual(["apps.named_mcp_gateways", "apps.access_profiles"]); + }); + + it.each([ + "installed", + "disabled", + "error", + "upgrade_pending", + "uninstalled", + ])("fails closed while EE is %s", (status) => { + expect(resolveHostFeaturesFromProvider({ + pluginKey: manifest.id, + status, + manifestJson: manifest, + }).hostFeatures).toEqual([]); + }); + + it("fails closed for a missing, unauthorized, or malformed provider", () => { + expect(resolveHostFeaturesFromProvider(null).hostFeatures).toEqual([]); + expect(resolveHostFeaturesFromProvider({ + pluginKey: "acme.paperclip-ee", + status: "ready", + manifestJson: { ...manifest, id: "acme.paperclip-ee" }, + }).hostFeatures).toEqual([]); + expect(resolveHostFeaturesFromProvider({ + pluginKey: manifest.id, + status: "ready", + manifestJson: { ...manifest, hostFeatures: { schemaVersion: 1, features: ["apps.unknown"] } } as never, + }).hostFeatures).toEqual([]); + }); +}); diff --git a/server/src/services/host-features.ts b/server/src/services/host-features.ts new file mode 100644 index 00000000000..0208ec356c2 --- /dev/null +++ b/server/src/services/host-features.ts @@ -0,0 +1,71 @@ +import type { Db } from "@paperclipai/db"; +import type { + HostFeatureKey, + HostFeaturesSnapshotV1, + PaperclipPluginManifestV1, + PluginStatus, +} from "@paperclipai/shared"; +import { + HOST_FEATURE_KEYS, + PAPERCLIP_EE_PLUGIN_ID, + pluginManifestV1Schema, +} from "@paperclipai/shared"; +import { notFound } from "../errors.js"; +import { pluginRegistryService } from "./plugin-registry.js"; + +type HostFeatureProviderRecord = { + pluginKey: string; + status: PluginStatus; + manifestJson: PaperclipPluginManifestV1; +}; + +const EMPTY_SNAPSHOT: HostFeaturesSnapshotV1 = Object.freeze({ + schemaVersion: 1, + hostFeatures: [], +}); + +/** + * Resolve a stored provider record without trusting its JSONB shape. Only a + * ready canonical EE plugin with a currently valid declaration contributes. + */ +export function resolveHostFeaturesFromProvider( + provider: HostFeatureProviderRecord | null | undefined, +): HostFeaturesSnapshotV1 { + if ( + !provider + || provider.pluginKey !== PAPERCLIP_EE_PLUGIN_ID + || provider.status !== "ready" + ) { + return EMPTY_SNAPSHOT; + } + + const parsed = pluginManifestV1Schema.safeParse(provider.manifestJson); + if (!parsed.success || parsed.data.id !== PAPERCLIP_EE_PLUGIN_ID) { + return EMPTY_SNAPSHOT; + } + + const declared = new Set(parsed.data.hostFeatures?.features ?? []); + return { + schemaVersion: 1, + hostFeatures: HOST_FEATURE_KEYS.filter((feature) => declared.has(feature)), + }; +} + +/** Resolve the current instance-level host features from plugin lifecycle state. */ +export async function resolveHostFeatures(db: Db): Promise { + const provider = await pluginRegistryService(db).getByKey(PAPERCLIP_EE_PLUGIN_ID); + return resolveHostFeaturesFromProvider(provider); +} + +export async function hasHostFeature(db: Db, feature: HostFeatureKey): Promise { + return (await resolveHostFeatures(db)).hostFeatures.includes(feature); +} + +/** Fail closed with the stable product-unavailable response used by HTTP gates. */ +export async function requireHostFeature(db: Db, feature: HostFeatureKey): Promise { + if (await hasHostFeature(db, feature)) return; + throw notFound("Feature unavailable", { + code: "feature_unavailable", + feature, + }); +} diff --git a/server/src/static-index-html.ts b/server/src/static-index-html.ts index 13fa592c09a..355988db4d8 100644 --- a/server/src/static-index-html.ts +++ b/server/src/static-index-html.ts @@ -1,7 +1,13 @@ import fs from "node:fs"; import path from "node:path"; import { applyUiBranding } from "./ui-branding.js"; +import type { HostFeaturesSnapshotV1 } from "@paperclipai/shared"; +import { injectRuntimeFeaturesHtml } from "./runtime-features-html.js"; -export function readBrandedStaticIndexHtml(uiDist: string): string { - return applyUiBranding(fs.readFileSync(path.join(uiDist, "index.html"), "utf-8")); +export function readBrandedStaticIndexHtml( + uiDist: string, + runtimeFeatures?: HostFeaturesSnapshotV1, +): string { + const branded = applyUiBranding(fs.readFileSync(path.join(uiDist, "index.html"), "utf-8")); + return runtimeFeatures ? injectRuntimeFeaturesHtml(branded, runtimeFeatures) : branded; } diff --git a/server/src/vite-html-renderer.ts b/server/src/vite-html-renderer.ts index 983a3ea1586..706fe0fc65f 100644 --- a/server/src/vite-html-renderer.ts +++ b/server/src/vite-html-renderer.ts @@ -43,6 +43,7 @@ export function createCachedViteHtmlRenderer(opts: { vite: ViteWatcherHost; uiRoot: string; brandHtml?: (html: string) => string; + injectHtml?: (html: string) => string | Promise; }): CachedViteHtmlRenderer { const uiRoot = path.resolve(opts.uiRoot); const templatePath = path.resolve(uiRoot, "index.html"); @@ -74,7 +75,8 @@ export function createCachedViteHtmlRenderer(opts: { return { render(): Promise { - return Promise.resolve(loadHtml()); + const html = loadHtml(); + return Promise.resolve(opts.injectHtml ? opts.injectHtml(html) : html); }, dispose(): void {