mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(api): date deprecations and log which builds still call them
/api/feature-flags answered Deprecation: true and nothing else: a client learned the route was going, never when, and the log could not tell which builds still called it. deprecated() (src/lib/http/deprecation.ts) applies one reviewed notice per route: Deprecation as an RFC 9745 date, Sunset as an RFC 8594 HTTP-date, the successor Link, and a wide-event annotation with the client build read from the User-Agent. feature-flags was deprecated on 2026-09-24 (v1.39.0); its sunset is 2027-03-31. The headers are declared on the operation, and a test holds every notice to a deprecated operation that answers through the helper.
This commit is contained in:
1 parent
39d23dbb7d
commit
9fda88586c
6 files changed
+237
-10
No files matched your search
+17
-2
@@ -839,8 +839,9 @@ paths:
|
||||
Deprecated: read `ai` on GET /api/auth/me instead, which resolves each AI capability for the record from the
|
||||
switches, the record's modules, the provider, consent and who is asking. This route projects the operator
|
||||
switches alone and is removed in the first release after the native build that reads `ai` ships. Responses carry
|
||||
`Deprecation: true` and `Link: </api/auth/me>; rel="successor-version"`. The master is applied server-side, so
|
||||
every sub-switch is already false when it is off.
|
||||
`Deprecation` (the date it was deprecated, as `@<unix seconds>`), `Sunset` (the first day it may stop answering)
|
||||
and `Link: </api/auth/me>; rel="successor-version"`. The master is applied server-side, so every sub-switch is
|
||||
already false when it is off.
|
||||
|
||||
|
||||
An ACTOR surface: it answers about the deployment, not about a record, so it keeps answering while the caller is
|
||||
@@ -855,6 +856,20 @@ paths:
|
||||
responses:
|
||||
"200":
|
||||
description: The resolved assistant matrix.
|
||||
headers:
|
||||
Deprecation:
|
||||
description: "When this route was deprecated, as an RFC 9745 date: `@` and Unix seconds."
|
||||
schema:
|
||||
type: string
|
||||
pattern: ^@[0-9]+$
|
||||
Sunset:
|
||||
description: The first day this route may stop answering (RFC 8594 HTTP-date).
|
||||
schema:
|
||||
type: string
|
||||
Link:
|
||||
description: The successor, as `</api/auth/me>; rel="successor-version"`.
|
||||
schema:
|
||||
type: string
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
|
||||
@@ -150,7 +150,10 @@ describe("GET /api/feature-flags", () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
FIND.mockResolvedValue(null);
|
||||
const res = await GET(req());
|
||||
expect(res.headers.get("Deprecation")).toBe("true");
|
||||
// RFC 9745: the deprecation date as `@` and Unix seconds — 2026-09-24.
|
||||
expect(res.headers.get("Deprecation")).toBe("@1790208000");
|
||||
// RFC 8594: the first day it may stop answering.
|
||||
expect(res.headers.get("Sunset")).toBe("Wed, 31 Mar 2027 00:00:00 GMT");
|
||||
expect(res.headers.get("Link")).toBe(
|
||||
'</api/auth/me>; rel="successor-version"',
|
||||
);
|
||||
|
||||
@@ -12,8 +12,10 @@
|
||||
* "insightStatus", "documentAi" } } }
|
||||
*
|
||||
* The master is applied (every sub-switch reads false when it is off), and a
|
||||
* switch read that fails answers every switch off. The response carries
|
||||
* `Deprecation: true` and a `Link` to its successor.
|
||||
* switch read that fails answers every switch off. The response carries the
|
||||
* deprecation date, the sunset and a `Link` to its successor
|
||||
* (`src/lib/http/deprecation.ts`), and the wide event names the client build
|
||||
* that still asked.
|
||||
*
|
||||
* `Cache-Control: private, max-age=60`: switches flip rarely.
|
||||
*/
|
||||
@@ -22,9 +24,10 @@ import type { NextRequest } from "next/server";
|
||||
import { apiHandler, requireActorAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { getAssistantFlags } from "@/lib/feature-flags";
|
||||
import { deprecated } from "@/lib/http/deprecation";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export const GET = apiHandler(async (_request: NextRequest) => {
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// An actor surface, and the easiest call in the set: the answer comes off
|
||||
// the `AppSettings` singleton and reads no user row at all, so there is no
|
||||
// record for a switch to substitute. It stays reachable while a switch is on
|
||||
@@ -45,7 +48,5 @@ export const GET = apiHandler(async (_request: NextRequest) => {
|
||||
|
||||
const response = apiSuccess({ assistant });
|
||||
response.headers.set("Cache-Control", "private, max-age=60");
|
||||
response.headers.set("Deprecation", "true");
|
||||
response.headers.set("Link", '</api/auth/me>; rel="successor-version"');
|
||||
return response;
|
||||
return deprecated(response, "/api/feature-flags", request);
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { buildOpenApiDocument } from "@/lib/openapi/registry";
|
||||
|
||||
import { clientBuildOf, deprecated, DEPRECATIONS } from "../deprecation";
|
||||
|
||||
beforeEach(() => vi.mocked(annotate).mockClear());
|
||||
|
||||
function request(userAgent?: string): Request {
|
||||
return new Request("http://localhost/api/feature-flags", {
|
||||
headers: userAgent ? { "user-agent": userAgent } : {},
|
||||
});
|
||||
}
|
||||
|
||||
describe("deprecated()", () => {
|
||||
it("dates the deprecation and the sunset, and names the successor", () => {
|
||||
const res = deprecated(
|
||||
new Response(null, { headers: { Link: '</a>; rel="next"' } }),
|
||||
"/api/feature-flags",
|
||||
request(),
|
||||
);
|
||||
const notice = DEPRECATIONS["/api/feature-flags"];
|
||||
expect(res.headers.get("Deprecation")).toBe(
|
||||
`@${Date.parse(`${notice.since}T00:00:00Z`) / 1000}`,
|
||||
);
|
||||
expect(new Date(res.headers.get("Sunset")!).toISOString()).toBe(
|
||||
`${notice.sunset}T00:00:00.000Z`,
|
||||
);
|
||||
// A Link the route already set survives beside the successor.
|
||||
expect(res.headers.get("Link")).toBe(
|
||||
'</a>; rel="next", </api/auth/me>; rel="successor-version"',
|
||||
);
|
||||
});
|
||||
|
||||
it("logs which client build still asked", () => {
|
||||
deprecated(
|
||||
new Response(null),
|
||||
"/api/feature-flags",
|
||||
request("HealthLog-iOS/1.2.0 (dev.healthlog.app)"),
|
||||
);
|
||||
expect(annotate).toHaveBeenCalledWith({
|
||||
meta: {
|
||||
deprecated_route: "/api/feature-flags",
|
||||
deprecation_sunset: DEPRECATIONS["/api/feature-flags"].sunset,
|
||||
client_build: "ios/1.2.0",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("sunsets every notice after it was deprecated", () => {
|
||||
for (const notice of Object.values(DEPRECATIONS)) {
|
||||
expect(Date.parse(notice.sunset)).toBeGreaterThan(
|
||||
Date.parse(notice.since),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("clientBuildOf", () => {
|
||||
it("reads the native build, a browser, anything else, and nothing", () => {
|
||||
expect(clientBuildOf("HealthLog-iOS/1.3.0 (dev.healthlog.app)")).toBe(
|
||||
"ios/1.3.0",
|
||||
);
|
||||
expect(
|
||||
clientBuildOf("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)"),
|
||||
).toBe("web");
|
||||
expect(clientBuildOf("curl/8.4.0")).toBe("other");
|
||||
expect(clientBuildOf(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("each deprecation is published and applied", () => {
|
||||
const doc = buildOpenApiDocument() as unknown as {
|
||||
paths: Record<string, Record<string, { deprecated?: boolean }>>;
|
||||
};
|
||||
|
||||
it.each(Object.keys(DEPRECATIONS))(
|
||||
"%s is deprecated in the contract and answers through the helper",
|
||||
(route) => {
|
||||
const ops = Object.values(doc.paths[route] ?? {});
|
||||
expect(ops.length).toBeGreaterThan(0);
|
||||
expect(ops.every((op) => op.deprecated === true)).toBe(true);
|
||||
const src = readFileSync(
|
||||
join(process.cwd(), "src/app", route, "route.ts"),
|
||||
"utf8",
|
||||
);
|
||||
expect(src).toContain(`deprecated(response, "${route}", request)`);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* How a deprecated route says so: on the wire, and in the operator's log.
|
||||
*
|
||||
* `/api/feature-flags` was marked deprecated with a bare `Deprecation: true`
|
||||
* and no date at all: a client could learn that the route was going, never
|
||||
* when, and the operator could not tell which builds still called it, so the
|
||||
* removal criterion ("the first release after the native build that reads
|
||||
* `ai` ships") could not be checked against anything. A deprecation is now one
|
||||
* reviewed notice per route, applied with {@link deprecated}:
|
||||
*
|
||||
* - `Deprecation: @<unix seconds>` — when it was deprecated (RFC 9745);
|
||||
* - `Sunset: <HTTP-date>` — from when the route may stop answering
|
||||
* (RFC 8594);
|
||||
* - `Link: <successor>; rel="successor-version"` — where to go instead;
|
||||
* - a wide-event annotation naming the route's notice and the calling
|
||||
* client build, read off the `User-Agent`, so the log answers "who still
|
||||
* calls this" before the sunset arrives.
|
||||
*
|
||||
* The notices live in {@link DEPRECATIONS}, so the dates are in one place and
|
||||
* `deprecation.test.ts` holds each route that applies one to the OpenAPI
|
||||
* `deprecated` flag.
|
||||
*/
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
export interface DeprecationNotice {
|
||||
/** The day the route was deprecated, `YYYY-MM-DD` (UTC). */
|
||||
since: string;
|
||||
/** The first day the route may stop answering, `YYYY-MM-DD` (UTC). */
|
||||
sunset: string;
|
||||
/** The path that replaces it, when there is one. */
|
||||
successor?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every route that answers as deprecated, with its dates.
|
||||
*
|
||||
* `/api/feature-flags`: deprecated in v1.39.0 (2026-09-24), when `ai` on
|
||||
* `GET /api/auth/me` replaced it. The shipped native build still reads it, so
|
||||
* the sunset leaves two native release cycles.
|
||||
*/
|
||||
export const DEPRECATIONS = {
|
||||
"/api/feature-flags": {
|
||||
since: "2026-09-24",
|
||||
sunset: "2027-03-31",
|
||||
successor: "/api/auth/me",
|
||||
},
|
||||
} as const satisfies Record<string, DeprecationNotice>;
|
||||
|
||||
export type DeprecatedRoute = keyof typeof DEPRECATIONS;
|
||||
|
||||
function utcDay(day: string): Date {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) {
|
||||
throw new Error(`Deprecation date must be YYYY-MM-DD, got "${day}"`);
|
||||
}
|
||||
return new Date(`${day}T00:00:00.000Z`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The calling client, coarsely: `ios/<version>` for the native app (its
|
||||
* `User-Agent` is `HealthLog-iOS/<version> (<bundle id>)`), `web` for a
|
||||
* browser, `other` for anything else, null with no header.
|
||||
*/
|
||||
export function clientBuildOf(userAgent: string | null): string | null {
|
||||
if (!userAgent) return null;
|
||||
const ios = /^HealthLog-iOS\/([0-9][0-9A-Za-z.+-]{0,31})/.exec(userAgent);
|
||||
if (ios) return `ios/${ios[1]}`;
|
||||
if (/^Mozilla\//.test(userAgent)) return "web";
|
||||
return "other";
|
||||
}
|
||||
|
||||
/** Mark a response as coming from a deprecated route, and log who asked. */
|
||||
export function deprecated<R extends Response>(
|
||||
response: R,
|
||||
route: DeprecatedRoute,
|
||||
request: Request,
|
||||
): R {
|
||||
const notice: DeprecationNotice = DEPRECATIONS[route];
|
||||
const since = utcDay(notice.since);
|
||||
const sunset = utcDay(notice.sunset);
|
||||
response.headers.set("Deprecation", `@${Math.floor(since.getTime() / 1000)}`);
|
||||
response.headers.set("Sunset", sunset.toUTCString());
|
||||
if (notice.successor) {
|
||||
response.headers.append(
|
||||
"Link",
|
||||
`<${notice.successor}>; rel="successor-version"`,
|
||||
);
|
||||
}
|
||||
annotate({
|
||||
meta: {
|
||||
deprecated_route: route,
|
||||
deprecation_sunset: notice.sunset,
|
||||
client_build: clientBuildOf(request.headers.get("user-agent")),
|
||||
},
|
||||
});
|
||||
return response;
|
||||
}
|
||||
@@ -394,7 +394,7 @@ export const metaPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
summary: "The operator's assistant switches (deprecated)",
|
||||
deprecated: true,
|
||||
description:
|
||||
'Deprecated: read `ai` on GET /api/auth/me instead, which resolves each AI capability for the record from the switches, the record\'s modules, the provider, consent and who is asking. This route projects the operator switches alone and is removed in the first release after the native build that reads `ai` ships. Responses carry `Deprecation: true` and `Link: </api/auth/me>; rel="successor-version"`. The master is applied server-side, so every sub-switch is already false when it is off.\n\n' +
|
||||
'Deprecated: read `ai` on GET /api/auth/me instead, which resolves each AI capability for the record from the switches, the record\'s modules, the provider, consent and who is asking. This route projects the operator switches alone and is removed in the first release after the native build that reads `ai` ships. Responses carry `Deprecation` (the date it was deprecated, as `@<unix seconds>`), `Sunset` (the first day it may stop answering) and `Link: </api/auth/me>; rel="successor-version"`. The master is applied server-side, so every sub-switch is already false when it is off.\n\n' +
|
||||
"An ACTOR surface: it answers about the deployment, not about a record, so it keeps answering while the caller is acting on somebody else's — the Coach launcher and the assistant chrome are gated on it, and a refusal here would delete a piece of the shell rather than a piece of the data. A request that attaches the per-request account selector (the `AccountSelector` header parameter) is refused with 403 `sharing.not_permitted` rather than quietly answered.\n\n" +
|
||||
"Served with `Cache-Control: private, max-age=60`: an operator's toggle propagates within a minute, and the flag read stays off the hot mount path in the meantime.",
|
||||
responses: {
|
||||
@@ -408,6 +408,23 @@ export const metaPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
),
|
||||
},
|
||||
},
|
||||
headers: {
|
||||
Deprecation: {
|
||||
description:
|
||||
"When this route was deprecated, as an RFC 9745 date: `@` and Unix seconds.",
|
||||
schema: { type: "string" as const, pattern: "^@[0-9]+$" },
|
||||
},
|
||||
Sunset: {
|
||||
description:
|
||||
"The first day this route may stop answering (RFC 8594 HTTP-date).",
|
||||
schema: { type: "string" as const },
|
||||
},
|
||||
Link: {
|
||||
description:
|
||||
'The successor, as `</api/auth/me>; rel="successor-version"`.',
|
||||
schema: { type: "string" as const },
|
||||
},
|
||||
},
|
||||
},
|
||||
"403": {
|
||||
description:
|
||||
|
||||
Reference in new issue
Block a user