fix(server): flatten feature unavailable responses

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-22 19:38:17 +00:00
1 parent 839bbd67a3
commit fce1281894
5 files changed
+26 -5

No files matched your search

+1 -1
View File
@@ -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": "<host-feature-key>" }`.
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.
@@ -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 = {
@@ -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",
});
}
+1 -1
View File
@@ -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",
+3 -1
View File
@@ -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 }
: {}),
});