feat(plugins): enforce server-authoritative EE host features

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-22 19:38:10 +00:00
1 parent cc42a67e7e
commit 89358f38bf
24 files changed
+690 -49

No files matched your search

+20
View File
@@ -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.
*
+8
View File
@@ -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,
+2
View File
@@ -975,6 +975,8 @@ export type {
PluginLauncherRenderContextSnapshot,
PluginLauncherDeclaration,
PluginMinimumHostVersion,
PluginHostFeaturesDeclarationV1,
HostFeaturesSnapshotV1,
PluginUiDeclaration,
PluginDatabaseDeclaration,
PluginApiRouteCompanyResolution,
+19
View File
@@ -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). */
+2
View File
@@ -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",
+31
View File
@@ -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"],
});
});
});
+16 -3
View File
@@ -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);
+77 -2
View File
@@ -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
View File
@@ -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;
+14
View File
@@ -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",
+15
View File
@@ -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;
}
+21 -10
View File
@@ -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;
+31 -13
View File
@@ -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) => {
+26
View File
@@ -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}`;
}
+57
View File
@@ -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([]);
});
});
+71
View File
@@ -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,
});
}
+8 -2
View File
@@ -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;
}
+3 -1
View File
@@ -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 {