diff --git a/doc/SPEC-implementation.md b/doc/SPEC-implementation.md index 09df2d1c08e..b082bc33b68 100644 --- a/doc/SPEC-implementation.md +++ b/doc/SPEC-implementation.md @@ -642,7 +642,7 @@ The approved term set is: Core owns the closed, versioned host-feature keys `apps.named_mcp_gateways` and `apps.access_profiles`. Only the canonical `paperclipai.paperclip-ee` plugin may declare them in `PaperclipPluginManifestV1.hostFeatures`, and only a stored plugin record in `ready` state enables its valid declaration. Missing, installed, disabled, error, upgrade-pending, uninstalled, malformed, or non-canonical providers resolve to an empty feature set. -The server injects the non-sensitive effective feature snapshot into both static and Vite-served HTML before React starts, and exposes the same snapshot through an authenticated API for lifecycle revalidation. UI visibility is advisory; server routes remain authoritative. Named-gateway management, token, metadata, public, and ID-based MCP routes require `apps.named_mcp_gateways`. Profile administration routes require `apps.access_profiles`. Unavailable management routes return `404` with `code: "feature_unavailable"`. +The server injects the non-sensitive effective feature snapshot into both static and Vite-served HTML before React starts, and exposes the same snapshot through an authenticated API for lifecycle revalidation. UI visibility is advisory; server routes remain authoritative. Named-gateway management, token, metadata, public, and ID-based MCP routes require `apps.named_mcp_gateways`. Profile administration routes require `apps.access_profiles`. Unavailable management routes return `404` with the flat body `{ "error": "Feature unavailable", "code": "feature_unavailable", "feature": "" }`. Core profile list/effective reads, internal tool-gateway sessions, and runtime policy enforcement stay available in all editions. Feature disable and plugin uninstall do not delete gateway, profile, binding, entry, token, or policy rows; returning the canonical provider to `ready` restores the management surfaces over the existing data. diff --git a/server/src/__tests__/error-handler.test.ts b/server/src/__tests__/error-handler.test.ts index cfd65a23a2e..a37d795415f 100644 --- a/server/src/__tests__/error-handler.test.ts +++ b/server/src/__tests__/error-handler.test.ts @@ -112,6 +112,25 @@ describe("errorHandler", () => { expect(res.__errorContext).toBeUndefined(); }); + it("flattens feature-unavailable metadata into the stable response shape", () => { + const req = makeReq(); + const res = makeRes(); + const next = vi.fn() as unknown as NextFunction; + const err = new HttpError(404, "Feature unavailable", { + code: "feature_unavailable", + feature: "apps.access_profiles", + }); + + errorHandler(err, req, res, next); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ + error: "Feature unavailable", + code: "feature_unavailable", + feature: "apps.access_profiles", + }); + }); + it("records responsible-user denial codes on the active agent run", () => { const db = { marker: "db" }; const req = { diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts index 2cd67266e8c..ce4b1488b85 100644 --- a/server/src/__tests__/tool-access-service.test.ts +++ b/server/src/__tests__/tool-access-service.test.ts @@ -534,10 +534,10 @@ describeEmbeddedPostgres("tool access service", () => { ]; for (const response of gatedResponses) { expect(response.status).toBe(404); - expect(response.body).toMatchObject({ + expect(response.body).toEqual({ error: "Feature unavailable", code: "feature_unavailable", - details: { code: "feature_unavailable", feature: "apps.access_profiles" }, + feature: "apps.access_profiles", }); } diff --git a/server/src/__tests__/tool-gateway.test.ts b/server/src/__tests__/tool-gateway.test.ts index 26147d1b4a3..d24b0471772 100644 --- a/server/src/__tests__/tool-gateway.test.ts +++ b/server/src/__tests__/tool-gateway.test.ts @@ -584,7 +584,7 @@ describeEmbeddedPostgres("tool gateway acceptance", () => { for (const response of responses) { expect(response.status).toBe(404); - expect(response.body).toMatchObject({ + expect(response.body).toEqual({ error: "Feature unavailable", code: "feature_unavailable", feature: "apps.named_mcp_gateways", diff --git a/server/src/middleware/error-handler.ts b/server/src/middleware/error-handler.ts index 0a4aa1a1703..4df7e1d7526 100644 --- a/server/src/middleware/error-handler.ts +++ b/server/src/middleware/error-handler.ts @@ -91,6 +91,7 @@ export function errorHandler( : null; const redactedSkillPolicyDenial = isRedactedSkillPolicyDenial(details); const workspaceRepairPreconditionFailure = details?.code === "workspace_repair_precondition_failed"; + const featureUnavailable = details?.code === "feature_unavailable"; const structuredConnectionError = new Set([ "user_authorization_required", "grant_revoked", @@ -113,6 +114,7 @@ export function errorHandler( res.status(err.status).json({ error: err.message, ...(typeof details?.code === "string" ? { code: details.code } : {}), + ...(featureUnavailable && typeof details?.feature === "string" ? { feature: details.feature } : {}), ...(redactedSkillPolicyDenial && typeof details?.reason === "string" ? { reason: details.reason } : {}), ...(workspaceRepairPreconditionFailure && typeof details?.reason === "string" ? { reason: details.reason } : {}), ...(workspaceRepairPreconditionFailure && typeof details?.repairPhase === "string" @@ -124,7 +126,7 @@ export function errorHandler( ...(structuredConnectionError && details?.connection ? { connection: details.connection } : {}), ...(structuredConnectionError && details?.subject ? { subject: details.subject } : {}), ...(structuredConnectionError && typeof details?.grantId === "string" ? { grantId: details.grantId } : {}), - ...(!redactedSkillPolicyDenial && !workspaceRepairPreconditionFailure && err.details + ...(!redactedSkillPolicyDenial && !workspaceRepairPreconditionFailure && !featureUnavailable && err.details ? { details: err.details } : {}), });