mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 12:04:41 +01:00
feat(plugins): enforce server-authoritative EE host features
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
cc42a67e7e
commit
89358f38bf
24 files changed
+690
-49
No files matched your search
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -975,6 +975,8 @@ export type {
|
||||
PluginLauncherRenderContextSnapshot,
|
||||
PluginLauncherDeclaration,
|
||||
PluginMinimumHostVersion,
|
||||
PluginHostFeaturesDeclarationV1,
|
||||
HostFeaturesSnapshotV1,
|
||||
PluginUiDeclaration,
|
||||
PluginDatabaseDeclaration,
|
||||
PluginApiRouteCompanyResolution,
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -50,6 +50,7 @@ const apiPrefixes: Record<string, string> = {
|
||||
"plugins.ts": "/api",
|
||||
"projects.ts": "/api",
|
||||
"resource-memberships.ts": "/api",
|
||||
"runtime-features.ts": "/api",
|
||||
"routines.ts": "/api",
|
||||
"secrets.ts": "/api",
|
||||
"sidebar-badges.ts": "/api",
|
||||
|
||||
@@ -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(
|
||||
"<html><head><!-- PAPERCLIP_RUNTIME_FEATURES --></head></html>",
|
||||
{ schemaVersion: 1, hostFeatures: [] },
|
||||
);
|
||||
expect(html).toContain('<script id="paperclip-runtime-features" type="application/json">');
|
||||
expect(html).not.toContain("PAPERCLIP_RUNTIME_FEATURES");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
|
||||
const resolveHostFeatures = vi.hoisted(() => vi.fn());
|
||||
vi.mock("../services/host-features.js", () => ({ resolveHostFeatures }));
|
||||
|
||||
import { runtimeFeatureRoutes } from "../routes/runtime-features.js";
|
||||
|
||||
function createApp(authenticated: boolean) {
|
||||
const app = express();
|
||||
app.use((req, _res, next) => {
|
||||
req.actor = authenticated
|
||||
? {
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
userName: "Board User",
|
||||
userEmail: null,
|
||||
isInstanceAdmin: true,
|
||||
source: "local_implicit",
|
||||
}
|
||||
: { type: "none", source: "none" };
|
||||
next();
|
||||
});
|
||||
app.use("/api", runtimeFeatureRoutes({} as never));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("runtime feature revalidation route", () => {
|
||||
it("requires authentication and returns the current snapshot", async () => {
|
||||
resolveHostFeatures.mockResolvedValue({
|
||||
schemaVersion: 1,
|
||||
hostFeatures: ["apps.access_profiles"],
|
||||
});
|
||||
|
||||
const unauthenticated = await request(createApp(false)).get("/api/runtime-features");
|
||||
expect(unauthenticated.status).toBe(401);
|
||||
|
||||
const authenticated = await request(createApp(true)).get("/api/runtime-features");
|
||||
expect(authenticated.status).toBe(200);
|
||||
expect(authenticated.body).toEqual({
|
||||
schemaVersion: 1,
|
||||
hostFeatures: ["apps.access_profiles"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -20,13 +20,19 @@ describe("static SPA fallback HTML", () => {
|
||||
tempDirs.push(tempDir);
|
||||
const indexPath = path.join(tempDir, "index.html");
|
||||
const app = express();
|
||||
app.get(/.*/, (_req, res) => {
|
||||
const serveIndex = (_req: express.Request, res: express.Response) => {
|
||||
res
|
||||
.status(200)
|
||||
.set("Content-Type", "text/html")
|
||||
.set("Cache-Control", "no-cache")
|
||||
.end(readBrandedStaticIndexHtml(tempDir));
|
||||
});
|
||||
.end(readBrandedStaticIndexHtml(tempDir, {
|
||||
schemaVersion: 1,
|
||||
hostFeatures: ["apps.access_profiles"],
|
||||
}));
|
||||
};
|
||||
app.get(["/", "/index.html"], serveIndex);
|
||||
app.use(express.static(tempDir, { index: false }));
|
||||
app.get(/.*/, serveIndex);
|
||||
|
||||
fs.writeFileSync(
|
||||
indexPath,
|
||||
@@ -45,5 +51,12 @@ describe("static SPA fallback HTML", () => {
|
||||
const res = await request(app).get("/PAP/issues/PAP-9939");
|
||||
expect(res.text).toContain("/assets/index-new.js");
|
||||
expect(res.text).not.toContain("/assets/index-old.js");
|
||||
expect(res.text).toContain('id="paperclip-runtime-features"');
|
||||
expect(res.text).toContain('"hostFeatures":["apps.access_profiles"]');
|
||||
|
||||
const root = await request(app).get("/");
|
||||
const explicitIndex = await request(app).get("/index.html");
|
||||
expect(root.text).toContain('id="paperclip-runtime-features"');
|
||||
expect(explicitIndex.text).toContain('id="paperclip-runtime-features"');
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
agents,
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
issueThreadInteractions,
|
||||
issues,
|
||||
principalPermissionGrants,
|
||||
plugins,
|
||||
secretAccessEvents,
|
||||
toolAccessAuditEvents,
|
||||
toolActionRequests,
|
||||
@@ -428,6 +429,33 @@ describeEmbeddedPostgres("tool access service", () => {
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 20_000);
|
||||
|
||||
beforeEach(async () => {
|
||||
await db.insert(plugins).values({
|
||||
pluginKey: "paperclipai.paperclip-ee",
|
||||
packageName: "@paperclipai/paperclip-ee",
|
||||
version: "0.1.0",
|
||||
apiVersion: 1,
|
||||
categories: ["ui"],
|
||||
manifestJson: {
|
||||
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"],
|
||||
},
|
||||
},
|
||||
status: "ready",
|
||||
installOrder: 1,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
@@ -459,12 +487,79 @@ describeEmbeddedPostgres("tool access service", () => {
|
||||
await db.delete(companyMemberships);
|
||||
await db.delete(companies);
|
||||
await db.delete(authUsers);
|
||||
await db.delete(plugins);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
it("preserves profile data while disabled or uninstalled and restores it on re-enable", async () => {
|
||||
const company = await createCompany(db);
|
||||
const service = toolAccessService(db);
|
||||
const profile = await service.createProfile(company.id, {
|
||||
profileKey: `preserved-profile-${randomUUID()}`,
|
||||
name: "Preserved profile",
|
||||
defaultAction: "deny",
|
||||
});
|
||||
const entry = await service.addProfileEntry(profile.id, {
|
||||
selectorType: "tool_name",
|
||||
effect: "include",
|
||||
toolName: "read_notes",
|
||||
});
|
||||
await db
|
||||
.update(plugins)
|
||||
.set({ status: "disabled" })
|
||||
.where(eq(plugins.pluginKey, "paperclipai.paperclip-ee"));
|
||||
|
||||
const app = createRouteApp(db);
|
||||
const listWhileDisabled = await request(app).get(`/api/companies/${company.id}/tools/profiles`);
|
||||
expect(listWhileDisabled.status).toBe(200);
|
||||
expect(listWhileDisabled.body.profiles).toContainEqual(
|
||||
expect.objectContaining({ id: profile.id, name: "Preserved profile" }),
|
||||
);
|
||||
|
||||
const gatedResponses = [
|
||||
await request(app).get(`/api/tool-profiles/${profile.id}/new-tools`),
|
||||
await request(app).post(`/api/companies/${company.id}/tools/profiles`).send({}),
|
||||
await request(app).patch(`/api/tool-profiles/${profile.id}`).send({}),
|
||||
await request(app).post(`/api/tool-profiles/${profile.id}/duplicate`).send({}),
|
||||
await request(app).delete(`/api/tool-profiles/${profile.id}`).send({}),
|
||||
await request(app).post(`/api/tool-profiles/${profile.id}/new-tools/review`).send({}),
|
||||
await request(app).post(`/api/tool-profiles/${profile.id}/entries`).send({}),
|
||||
await request(app).patch(`/api/tool-profile-entries/${entry.id}`).send({}),
|
||||
await request(app).delete(`/api/tool-profile-entries/${entry.id}`),
|
||||
await request(app).post(`/api/companies/${company.id}/tools/profiles/${profile.id}/bind`).send({}),
|
||||
await request(app).post(`/api/companies/${company.id}/tools/profiles/${profile.id}/unbind`).send({}),
|
||||
];
|
||||
for (const response of gatedResponses) {
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toMatchObject({
|
||||
error: "Feature unavailable",
|
||||
code: "feature_unavailable",
|
||||
details: { code: "feature_unavailable", feature: "apps.access_profiles" },
|
||||
});
|
||||
}
|
||||
|
||||
await db
|
||||
.update(plugins)
|
||||
.set({ status: "uninstalled" })
|
||||
.where(eq(plugins.pluginKey, "paperclipai.paperclip-ee"));
|
||||
const listWhileUninstalled = await request(app).get(`/api/companies/${company.id}/tools/profiles`);
|
||||
expect(listWhileUninstalled.status).toBe(200);
|
||||
expect(listWhileUninstalled.body.profiles).toContainEqual(expect.objectContaining({ id: profile.id }));
|
||||
|
||||
await db
|
||||
.update(plugins)
|
||||
.set({ status: "ready" })
|
||||
.where(eq(plugins.pluginKey, "paperclipai.paperclip-ee"));
|
||||
const restored = await request(app)
|
||||
.patch(`/api/tool-profiles/${profile.id}`)
|
||||
.send({ name: "Restored profile" });
|
||||
expect(restored.status).toBe(200);
|
||||
expect(restored.body).toMatchObject({ id: profile.id, name: "Restored profile" });
|
||||
});
|
||||
|
||||
it("mints generic exchange connection tokens through the agent route and stores only hashes", async () => {
|
||||
const company = await createCompany(db);
|
||||
const agent = await createAgent(db, company.id);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createServer, type IncomingMessage } from "node:http";
|
||||
import express from "express";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import request from "supertest";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
agents,
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
issueThreadInteractions,
|
||||
issues,
|
||||
principalPermissionGrants,
|
||||
plugins,
|
||||
projects,
|
||||
toolAccessAuditEvents,
|
||||
toolActionRequests,
|
||||
@@ -410,7 +411,7 @@ function createGatewayRouteApp(
|
||||
next();
|
||||
});
|
||||
}
|
||||
app.use(mcpGatewayProtocolRoutes(gateway));
|
||||
app.use(mcpGatewayProtocolRoutes(db, gateway));
|
||||
app.use("/api", toolGatewayRoutes(db, gateway));
|
||||
return app;
|
||||
}
|
||||
@@ -488,6 +489,33 @@ describeEmbeddedPostgres("tool gateway acceptance", () => {
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 20_000);
|
||||
|
||||
beforeEach(async () => {
|
||||
await db.insert(plugins).values({
|
||||
pluginKey: "paperclipai.paperclip-ee",
|
||||
packageName: "@paperclipai/paperclip-ee",
|
||||
version: "0.1.0",
|
||||
apiVersion: 1,
|
||||
categories: ["ui"],
|
||||
manifestJson: {
|
||||
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"],
|
||||
},
|
||||
},
|
||||
status: "ready",
|
||||
installOrder: 1,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(activityLog);
|
||||
await db.delete(toolCallEvents);
|
||||
@@ -517,12 +545,59 @@ describeEmbeddedPostgres("tool gateway acceptance", () => {
|
||||
await db.delete(companyMemberships);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
await db.delete(plugins);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
it("fails closed for every named-gateway surface while internal sessions remain available", async () => {
|
||||
const company = await createCompany(db);
|
||||
const agent = await createAgent(db, company.id);
|
||||
const { run } = await createIssueAndRun(db, company.id, agent.id);
|
||||
await db
|
||||
.update(plugins)
|
||||
.set({ status: "disabled" })
|
||||
.where(eq(plugins.pluginKey, "paperclipai.paperclip-ee"));
|
||||
|
||||
const app = createGatewayRouteApp(db, createTestToolGatewayService(db), {
|
||||
type: "board",
|
||||
userId: "board-user",
|
||||
userName: "Board User",
|
||||
userEmail: null,
|
||||
isInstanceAdmin: true,
|
||||
source: "local_implicit",
|
||||
});
|
||||
const id = randomUUID();
|
||||
const responses = [
|
||||
await request(app).get(`/api/companies/${company.id}/tools/gateways`),
|
||||
await request(app).post(`/api/companies/${company.id}/tools/gateways`).send({}),
|
||||
await request(app).patch(`/api/tool-gateway/gateways/${id}`).send({ companyId: company.id }),
|
||||
await request(app).post(`/api/tool-gateway/gateways/${id}/tokens`).send({ companyId: company.id }),
|
||||
await request(app).post(`/api/tool-gateway/gateway-tokens/${id}/revoke`).send({ companyId: company.id }),
|
||||
await request(app).get(`/api/tool-gateway/gateways/${id}/mcp`),
|
||||
await request(app).post(`/api/tool-gateway/gateways/${id}/mcp`).send({}),
|
||||
await request(app).get(`/mcp/gateways/public-${id}`),
|
||||
await request(app).post(`/mcp/gateways/public-${id}`).send({}),
|
||||
];
|
||||
|
||||
for (const response of responses) {
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.body).toMatchObject({
|
||||
error: "Feature unavailable",
|
||||
code: "feature_unavailable",
|
||||
feature: "apps.named_mcp_gateways",
|
||||
});
|
||||
}
|
||||
|
||||
const internalSession = await request(app)
|
||||
.post("/api/tool-gateway/sessions")
|
||||
.send({ companyId: company.id, agentId: agent.id, runId: run.id });
|
||||
expect(internalSession.status).toBe(201);
|
||||
expect(internalSession.body).toMatchObject({ sessionId: expect.any(String), token: expect.any(String) });
|
||||
});
|
||||
|
||||
it("exposes a named gateway with scoped bearer-token auth and revocation", async () => {
|
||||
const company = await createCompany(db);
|
||||
const remote = await startFakeRemoteMcpServer(async () => ({
|
||||
|
||||
@@ -3,6 +3,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createCachedViteHtmlRenderer, type ViteWatcherHost } from "../vite-html-renderer.js";
|
||||
import { injectRuntimeFeaturesHtml } from "../runtime-features-html.js";
|
||||
|
||||
function createWatcher() {
|
||||
const listeners = new Map<string, Set<(file: string) => void>>();
|
||||
@@ -94,4 +95,32 @@ describe("createCachedViteHtmlRenderer", () => {
|
||||
expect(html.match(/\/@vite\/client/g)?.length).toBe(1);
|
||||
expect(html.match(/\/@react-refresh/g)?.length).toBe(1);
|
||||
});
|
||||
|
||||
it("injects a fresh runtime feature snapshot into the cached dev shell", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-vite-html-"));
|
||||
tempDirs.push(tempDir);
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, "index.html"),
|
||||
'<html><head><!-- PAPERCLIP_RUNTIME_FEATURES --></head><body><script type="module" src="/src/main.tsx"></script></body></html>',
|
||||
"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);
|
||||
});
|
||||
});
|
||||
+25
-17
@@ -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/<name>.<hash>.<ext>)
|
||||
// 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;
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<string, unknown> | 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;
|
||||
|
||||
@@ -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<string, string | string[] | undefi
|
||||
async function handleMcpGatewayProtocol(
|
||||
req: Request,
|
||||
res: Response,
|
||||
db: Db,
|
||||
toolGateway: ToolGatewayService,
|
||||
locator: { gatewayId?: string | null; gatewayPublicId?: string | null },
|
||||
) {
|
||||
try {
|
||||
await requireHostFeature(db, "apps.named_mcp_gateways");
|
||||
const token = bearerToken(req);
|
||||
if (!token) {
|
||||
res.status(401).json({ error: "Bearer token is required" });
|
||||
@@ -158,17 +161,22 @@ async function handleMcpGatewayProtocol(
|
||||
}
|
||||
}
|
||||
|
||||
export function mcpGatewayProtocolRoutes(toolGateway: ToolGatewayService) {
|
||||
export function mcpGatewayProtocolRoutes(db: Db, toolGateway: ToolGatewayService) {
|
||||
const router = Router();
|
||||
router.get("/mcp/gateways/:gatewayPublicId", async (req, res) => {
|
||||
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) => {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { HostFeaturesSnapshotV1 } from "@paperclipai/shared";
|
||||
|
||||
export const RUNTIME_FEATURES_HTML_MARKER = "<!-- PAPERCLIP_RUNTIME_FEATURES -->";
|
||||
|
||||
/** 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, "\\u003c")
|
||||
.replace(/>/g, "\\u003e")
|
||||
.replace(/\u2028/g, "\\u2028")
|
||||
.replace(/\u2029/g, "\\u2029");
|
||||
}
|
||||
|
||||
export function injectRuntimeFeaturesHtml(
|
||||
html: string,
|
||||
snapshot: HostFeaturesSnapshotV1,
|
||||
): string {
|
||||
const script = `<script id="paperclip-runtime-features" type="application/json">${serializeRuntimeFeaturesForHtml(snapshot)}</script>`;
|
||||
if (html.includes(RUNTIME_FEATURES_HTML_MARKER)) {
|
||||
return html.replace(RUNTIME_FEATURES_HTML_MARKER, script);
|
||||
}
|
||||
return html.includes("</head>")
|
||||
? html.replace("</head>", ` ${script}\n</head>`)
|
||||
: `${script}\n${html}`;
|
||||
}
|
||||
@@ -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<PluginStatus>([
|
||||
"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([]);
|
||||
});
|
||||
});
|
||||
@@ -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<HostFeaturesSnapshotV1> {
|
||||
const provider = await pluginRegistryService(db).getByKey(PAPERCLIP_EE_PLUGIN_ID);
|
||||
return resolveHostFeaturesFromProvider(provider);
|
||||
}
|
||||
|
||||
export async function hasHostFeature(db: Db, feature: HostFeatureKey): Promise<boolean> {
|
||||
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<void> {
|
||||
if (await hasHostFeature(db, feature)) return;
|
||||
throw notFound("Feature unavailable", {
|
||||
code: "feature_unavailable",
|
||||
feature,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -43,6 +43,7 @@ export function createCachedViteHtmlRenderer(opts: {
|
||||
vite: ViteWatcherHost;
|
||||
uiRoot: string;
|
||||
brandHtml?: (html: string) => string;
|
||||
injectHtml?: (html: string) => string | Promise<string>;
|
||||
}): 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<string> {
|
||||
return Promise.resolve(loadHtml());
|
||||
const html = loadHtml();
|
||||
return Promise.resolve(opts.injectHtml ? opts.injectHtml(html) : html);
|
||||
},
|
||||
|
||||
dispose(): void {
|
||||
|
||||
Reference in new issue
Block a user