From d2b6f91023c012f95c68d5b22efa9baa41341b31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 20 Apr 2026 14:56:34 -0400 Subject: [PATCH] C4 follow-up #3: RFC 8594 Deprecation/Sunset headers + call counter on legacy hire routes --- src/main/api-server/deprecation-counter.ts | 36 ++++++++++++++++++ src/main/api-server/routes-agents.ts | 13 +++++++ src/main/api-server/routes-approvals.ts | 12 ++++++ tests/unit/deprecation-counter.test.ts | 43 ++++++++++++++++++++++ 4 files changed, 104 insertions(+) create mode 100644 src/main/api-server/deprecation-counter.ts create mode 100644 tests/unit/deprecation-counter.test.ts diff --git a/src/main/api-server/deprecation-counter.ts b/src/main/api-server/deprecation-counter.ts new file mode 100644 index 00000000..c9389992 --- /dev/null +++ b/src/main/api-server/deprecation-counter.ts @@ -0,0 +1,36 @@ +/** + * In-process counter for deprecated HTTP routes (C4-followup #3). + * + * The counters let us assert "legacy HTTP hire traffic has hit zero" + * before physically deleting the deprecated handlers. Keys are the + * route label the handler passes in (e.g. `POST /api/companies/:companyId/agents`). + * + * Scope is intentionally narrow: module-level Map, synchronous mutation, + * no IO, no fallback. An empty label is a programmer error — we throw + * so it surfaces in tests instead of silently recording under "". + * + * The counter is not exposed over HTTP yet (scope creep). Internal + * code reads via `readDeprecationCounters()`. + */ + +const deprecationCallCounters = new Map(); + +export function incrementDeprecationCall(routeLabel: string): void { + if (typeof routeLabel !== "string" || routeLabel.length === 0) { + throw new Error("incrementDeprecationCall requires a non-empty routeLabel"); + } + const previous = deprecationCallCounters.get(routeLabel) ?? 0; + deprecationCallCounters.set(routeLabel, previous + 1); +} + +export function readDeprecationCounters(): Record { + const snapshot: Record = {}; + for (const [label, count] of deprecationCallCounters) { + snapshot[label] = count; + } + return snapshot; +} + +export function resetDeprecationCounters(): void { + deprecationCallCounters.clear(); +} diff --git a/src/main/api-server/routes-agents.ts b/src/main/api-server/routes-agents.ts index cfe33a9d..bd58def4 100644 --- a/src/main/api-server/routes-agents.ts +++ b/src/main/api-server/routes-agents.ts @@ -10,8 +10,15 @@ import { } from "../agent-permissions"; import { resolveApprovalDecision } from "../approval-policy"; import { shouldAutoApprove } from "../execution-policy"; +import { incrementDeprecationCall } from "./deprecation-counter"; import { requireParam, type AgentApiServer } from "./server-core"; +// C4-followup #3: RFC 8594 deprecation headers for the legacy HTTP hire +// route. Sunset is the 30-day removal gate — if the counter stays zero +// through this date, delete the handler. +const LEGACY_HIRE_ROUTE_LABEL = "POST /api/companies/:companyId/agents"; +const LEGACY_HIRE_SUNSET = new Date("2026-05-20T00:00:00Z").toUTCString(); + export function registerAgentRoutes(server: AgentApiServer) { const db = server.db; const callbacks = server.callbacks; @@ -106,6 +113,12 @@ export function registerAgentRoutes(server: AgentApiServer) { * so residual callers are easy to audit. */ server.addRoute("POST", "/api/companies/:companyId/agents", (_req, res, params, token, body) => { + // RFC 8594 deprecation signaling — emit on every response from this + // handler regardless of downstream validation outcome. + res.setHeader("Deprecation", "true"); + res.setHeader("Sunset", LEGACY_HIRE_SUNSET); + res.setHeader("Link", '; rel="deprecation"'); + incrementDeprecationCall(LEGACY_HIRE_ROUTE_LABEL); const companyId = requireParam(params, "companyId"); if (companyId !== token.companyId) { server.json(res, 403, { error: "Company mismatch" }); diff --git a/src/main/api-server/routes-approvals.ts b/src/main/api-server/routes-approvals.ts index 56c2c033..eab189e5 100644 --- a/src/main/api-server/routes-approvals.ts +++ b/src/main/api-server/routes-approvals.ts @@ -3,8 +3,16 @@ import log from "electron-log/main.js"; import { approvalInputSchema } from "@shared/contracts"; import { resolveApprovalDecision } from "../approval-policy"; import { shouldAutoApprove } from "../execution-policy"; +import { incrementDeprecationCall } from "./deprecation-counter"; import { requireParam, type AgentApiServer } from "./server-core"; +// C4-followup #3: RFC 8594 deprecation headers for the legacy +// approve_ceo_strategy HTTP path. Only this approval type is +// deprecated — other types still use this route as first-class. +const LEGACY_APPROVE_CEO_STRATEGY_LABEL = + "POST /api/companies/:companyId/approvals type=approve_ceo_strategy"; +const LEGACY_APPROVE_CEO_STRATEGY_SUNSET = new Date("2026-05-20T00:00:00Z").toUTCString(); + const approvalListQuerySchema = z.object({ requestedByAgentId: z.string().uuid().optional(), relatedTaskId: z.string().uuid().optional(), @@ -43,6 +51,10 @@ export function registerApprovalRoutes(server: AgentApiServer) { } const input = parsed.data; if (input.type === "approve_ceo_strategy") { + res.setHeader("Deprecation", "true"); + res.setHeader("Sunset", LEGACY_APPROVE_CEO_STRATEGY_SUNSET); + res.setHeader("Link", '; rel="deprecation"'); + incrementDeprecationCall(LEGACY_APPROVE_CEO_STRATEGY_LABEL); log.warn( `[deprecated][C4] POST /api/companies/${companyId}/approvals type=approve_ceo_strategy — prefer the file_approval internal tool (caller agent=${token.agentId}).`, ); diff --git a/tests/unit/deprecation-counter.test.ts b/tests/unit/deprecation-counter.test.ts new file mode 100644 index 00000000..4ae8813b --- /dev/null +++ b/tests/unit/deprecation-counter.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + incrementDeprecationCall, + readDeprecationCounters, + resetDeprecationCounters, +} from "../../src/main/api-server/deprecation-counter"; + +/** + * C4-followup #3: the in-process deprecation counter backs the + * "has legacy traffic hit zero?" decision. Keep these tests tight — + * the module has one job. + */ +describe("deprecation counter", () => { + beforeEach(() => { + resetDeprecationCounters(); + }); + + it("increments and reads back the count for a single route", () => { + incrementDeprecationCall("POST /legacy/hire"); + incrementDeprecationCall("POST /legacy/hire"); + expect(readDeprecationCounters()).toEqual({ "POST /legacy/hire": 2 }); + }); + + it("tracks multiple route labels independently", () => { + incrementDeprecationCall("POST /legacy/hire"); + incrementDeprecationCall("POST /legacy/approvals"); + incrementDeprecationCall("POST /legacy/hire"); + expect(readDeprecationCounters()).toEqual({ + "POST /legacy/hire": 2, + "POST /legacy/approvals": 1, + }); + }); + + it("resets all counters back to empty", () => { + incrementDeprecationCall("POST /legacy/hire"); + resetDeprecationCounters(); + expect(readDeprecationCounters()).toEqual({}); + }); + + it("throws on an empty routeLabel instead of silently recording", () => { + expect(() => incrementDeprecationCall("")).toThrow(/non-empty routeLabel/); + }); +});