Merge branch 'main' into fix/v1396-small-fixes

This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-09-30 23:27:25 +02:00
commit 85d11bc364
39 files changed
+2981 -155

No files matched your search

@@ -1,11 +1,14 @@
/**
* v1.37.20 — restore preview: the summary read decrypts and validates the
* stored file through the SAME helpers the restore uses, and only ever
* reads. Watched red: written against the route before its annotate landed;
* the not-found and decrypt-failure arms were asserted first.
* Restore preview. A copy written with a preview is answered from it without
* opening the copy (#1031); a copy from before previews is read whole once,
* through the same helpers the restore uses, and what that read found is kept
* on the row. The verdicts (schema version, keys) are taken again on every
* read. Only a file that fails the schema is a 422 with that message; any
* other failure is the server's, not the file's.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
import { ZodError } from "zod/v4";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
@@ -23,9 +26,15 @@ vi.mock("@/lib/api-handler", () => ({
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
const findUniqueMock = vi.fn();
const updateManyMock = vi.fn();
const chunkCountMock = vi.fn();
vi.mock("@/lib/db", () => ({
prisma: {
dataBackup: { findUnique: (...a: unknown[]) => findUniqueMock(...a) },
dataBackup: {
findUnique: (...a: unknown[]) => findUniqueMock(...a),
updateMany: (...a: unknown[]) => updateManyMock(...a),
},
dataBackupChunk: { count: (...a: unknown[]) => chunkCountMock(...a) },
},
}));
@@ -34,16 +43,32 @@ const decryptMock = vi.fn();
// decrypts anything, so a stub of this module has to answer that too.
vi.mock("@/lib/crypto", () => ({
decrypt: (...a: unknown[]) => decryptMock(...a),
decryptBytes: vi.fn(),
getConfiguredKeyIds: () => ["v1"],
isStreamCiphertext: () => false,
decryptStream: vi.fn(),
}));
const openSpy = vi.fn();
vi.mock("@/lib/export/stored-backup", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/export/stored-backup")>();
return {
...actual,
openStoredBackup: (...a: Parameters<typeof actual.openStoredBackup>) => {
openSpy(...a);
return actual.openStoredBackup(...a);
},
};
});
const parseMock = vi.fn();
const summarizeMock = vi.fn();
const compatibleMock = vi.fn((version: string) => version !== "99");
vi.mock("@/lib/validations/backup", () => ({
parseBackupPayload: (...a: unknown[]) => parseMock(...a),
summarizeBackup: (...a: unknown[]) => summarizeMock(...a),
isCompatibleSchemaVersion: vi.fn(() => true),
isCompatibleSchemaVersion: (version: string) => compatibleMock(version),
// The measurements are read as a stream and counted one by one.
BACKUP_SCHEMA_VERSION: "2",
backupMeasurementSchema: {
@@ -52,52 +77,201 @@ vi.mock("@/lib/validations/backup", () => ({
}));
import { GET } from "../route";
import { storedCopyIdentity } from "@/lib/export/backup-preview";
const request = new NextRequest(
"http://localhost/api/admin/backups/b1/summary",
);
const params = { params: Promise.resolve({ id: "b1" }) };
const params = () => ({ params: Promise.resolve({ id: "b1" }) });
const ROW = {
id: "b1",
userId: "u1",
data: "ciphertext",
chunkCount: null,
chunkStreamId: null,
createdAt: new Date("2026-08-01T00:00:00Z"),
user: { id: "u1", username: "self-hoster" },
};
function storedPreview(overrides: Record<string, unknown> = {}) {
return {
version: 1,
copy: storedCopyIdentity(ROW),
summary: {
schemaVersion: "2",
userId: "u1",
exportedAt: "2026-08-01T00:00:00.000Z",
measurements: 1_800_000,
moodEntries: 3,
},
keys: [],
...overrides,
};
}
beforeEach(() => {
vi.clearAllMocks();
findUniqueMock.mockResolvedValue({
id: "b1",
userId: "u1",
data: "ciphertext",
chunkCount: null,
chunkStreamId: null,
createdAt: new Date("2026-08-01T00:00:00Z"),
user: { id: "u1", username: "self-hoster" },
});
findUniqueMock.mockResolvedValue({ ...ROW, preview: null });
updateManyMock.mockResolvedValue({ count: 1 });
// Twelve measurements in the file: the preview counts them from the stream.
decryptMock.mockReturnValue(
JSON.stringify({
measurements: Array.from({ length: 12 }, (_, i) => ({ id: `m${i}` })),
}),
);
parseMock.mockReturnValue({ schemaVersion: 3 });
summarizeMock.mockReturnValue({ measurements: 0, moodEntries: 3 });
parseMock.mockReturnValue({ schemaVersion: "2" });
summarizeMock.mockReturnValue({
schemaVersion: "2",
userId: "u1",
exportedAt: "2026-08-01T00:00:00.000Z",
measurements: 0,
moodEntries: 3,
});
});
describe("GET /api/admin/backups/[id]/summary", () => {
it("answers the file's counts without writing anything", async () => {
const res = await GET(request, params);
describe("GET /api/admin/backups/[id]/summary, from the stored preview", () => {
it("answers from the preview without opening the copy", async () => {
findUniqueMock.mockResolvedValue({ ...ROW, preview: storedPreview() });
const res = await GET(request, params());
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.summary).toEqual({ measurements: 12, moodEntries: 3 });
expect(body.data.summary).toMatchObject({
measurements: 1_800_000,
moodEntries: 3,
});
expect(body.data.owner).toBe("self-hoster");
expect(openSpy).not.toHaveBeenCalled();
expect(decryptMock).not.toHaveBeenCalled();
expect(updateManyMock).not.toHaveBeenCalled();
});
it("takes the schema verdict again against this release", async () => {
findUniqueMock.mockResolvedValue({
...ROW,
preview: storedPreview({
summary: {
schemaVersion: "99",
userId: "u1",
exportedAt: "2026-08-01T00:00:00.000Z",
measurements: 1,
},
}),
});
const res = await GET(request, params());
expect(res.status).toBe(422);
expect(openSpy).not.toHaveBeenCalled();
});
it("takes the key verdict again against this server's keys", async () => {
findUniqueMock.mockResolvedValue({
...ROW,
preview: storedPreview({
keys: [
{
keyId: "v7",
count: 4,
sections: ["moodEntries"],
sample: { value: "v7.AAAA", form: "string" },
},
// Only in the instance settings, which the restore leaves out.
{
keyId: "v8",
count: 1,
sections: ["appSettings"],
sample: null,
},
],
}),
});
const res = await GET(request, params());
expect(res.status).toBe(422);
expect(await res.json()).toMatchObject({
meta: { errorCode: "backup.key.missing", keyIds: ["v7"] },
});
expect(openSpy).not.toHaveBeenCalled();
});
it("still counts the pieces, and refuses a copy that lost one", async () => {
const chunked = {
...ROW,
data: null,
chunkCount: 3,
chunkStreamId: "s1",
};
findUniqueMock.mockResolvedValue({
...chunked,
preview: storedPreview({ copy: storedCopyIdentity(chunked) }),
});
chunkCountMock.mockResolvedValue(3);
expect((await GET(request, params())).status).toBe(200);
chunkCountMock.mockResolvedValue(2);
const res = await GET(request, params());
expect(res.status).toBe(422);
expect(await res.json()).toMatchObject({
meta: { errorCode: "backup.payload.undecryptable" },
});
expect(openSpy).not.toHaveBeenCalled();
});
it("ignores a preview that describes another copy", async () => {
findUniqueMock.mockResolvedValue({
...ROW,
preview: storedPreview({ copy: "chunks:other:3" }),
});
const res = await GET(request, params());
expect(res.status).toBe(200);
expect((await res.json()).data.summary.measurements).toBe(12);
expect(openSpy).toHaveBeenCalledTimes(1);
});
});
describe("GET /api/admin/backups/[id]/summary, a copy without a preview", () => {
it("reads the copy once and keeps what it found on the row", async () => {
const res = await GET(request, params());
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.summary).toMatchObject({
measurements: 12,
moodEntries: 3,
});
expect(openSpy).toHaveBeenCalledTimes(1);
await vi.waitFor(() => expect(updateManyMock).toHaveBeenCalledTimes(1));
const [call] = updateManyMock.mock.calls[0] as [
{ where: Record<string, unknown>; data: { preview: { copy: string } } },
];
// Only onto the copy it was read from.
expect(call.where).toEqual({
id: "b1",
chunkStreamId: null,
chunkCount: null,
});
expect(call.data.preview.copy).toBe(storedCopyIdentity(ROW));
});
it("shares one read between requests for the same copy", async () => {
const [a, b] = await Promise.all([
GET(request, params()),
GET(request, params()),
]);
expect(a.status).toBe(200);
expect(b.status).toBe(200);
expect(openSpy).toHaveBeenCalledTimes(1);
});
it("404s an unknown backup id", async () => {
findUniqueMock.mockResolvedValue(null);
await expect(GET(request, params)).rejects.toMatchObject({ status: 404 });
await expect(GET(request, params())).rejects.toMatchObject({
status: 404,
});
});
it("refuses a file it cannot decrypt with the documented 422", async () => {
decryptMock.mockImplementation(() => {
throw new Error("bad key");
});
const res = await GET(request, params);
const res = await GET(request, params());
// Bad stored input — a key dropped from `ENCRYPTION_KEYS`, or bytes that
// are not the ones written — and not a fault in this process, so it does
// not answer 500 and does not reach the error reporter as one.
@@ -105,13 +279,42 @@ describe("GET /api/admin/backups/[id]/summary", () => {
expect(await res.json()).toMatchObject({
meta: { errorCode: "backup.payload.undecryptable" },
});
expect(updateManyMock).not.toHaveBeenCalled();
});
it("422s a file that fails schema validation", async () => {
parseMock.mockImplementation(() => {
throw new Error("nope");
throw new ZodError([]);
});
const res = await GET(request, params);
const res = await GET(request, params());
expect(res.status).toBe(422);
expect((await res.json()).error).toBe(
"Backup payload failed schema validation",
);
expect(updateManyMock).not.toHaveBeenCalled();
});
it("422s a file that is not JSON", async () => {
decryptMock.mockReturnValue("{ not json");
const res = await GET(request, params());
expect(res.status).toBe(422);
expect((await res.json()).error).toBe(
"Backup payload failed schema validation",
);
});
it("does not call a server failure a schema failure", async () => {
parseMock.mockImplementation(() => {
throw new Error("out of memory");
});
// Thrown to the handler, which answers 500 and reports it.
await expect(GET(request, params())).rejects.toThrow("out of memory");
expect(updateManyMock).not.toHaveBeenCalled();
});
it("still answers when keeping the preview fails", async () => {
updateManyMock.mockRejectedValue(new Error("db gone"));
const res = await GET(request, params());
expect(res.status).toBe(200);
});
});
+200 -46
View File
@@ -4,33 +4,57 @@
*
* The restore dialog used to ask for the typed confirmation on nothing but
* a filename and a date; the counts only appeared in the audit row after
* the rows were already replaced. This read decrypts and schema-validates
* the stored file exactly the way the restore itself does (same decrypt,
* same `parseBackupPayload`, same compatibility gate) and answers with the
* `summarizeBackup` counts — so what the operator confirms is what the
* restore will write, derived from the same code path. Read-only; nothing
* is touched.
* the rows were already replaced. This answers with the `summarizeBackup`
* counts of the stored copy, and refuses the way the restore would refuse:
* a schema version this release cannot restore, or a key the copy's inner
* ciphertext needs and this server does not have.
*
* The counts are worked out while the copy is written and kept on its row
* (`DataBackup.preview`, `src/lib/export/backup-preview.ts`), so this answers
* without opening the copy. Reading the copy here took two minutes for an
* account of 1.8 million readings on slow hardware, long after the dialog had
* given up (#1031). Both verdicts are taken again on every read, from what
* the preview keeps, because the release and the server's keys can both
* change after the copy was written. The copy's pieces are still counted
* against the row (no piece is read), so a copy that lost one since is
* refused here, as the restore and the download refuse it.
*
* A copy written before previews existed has none. That one is read whole,
* once: the preview worked out from it is stored on the row, so the next
* request answers at once, and concurrent requests for the same copy share
* the one read.
*/
import { NextRequest } from "next/server";
import { ZodError } from "zod/v4";
import { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { apiHandler, HttpError, requireAdmin } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import {
checkStoredBackupShape,
isStoredBackupReadError,
openStoredBackup,
storedBackupRefusal,
STORED_BACKUP_SELECT,
type StoredBackupRef,
} from "@/lib/export/stored-backup";
import {
assessBackupKeys,
BACKUP_KEY_MISSING_CODE,
BackupKeyIdCollector,
describeBackupKeyProblem,
} from "@/lib/export/backup-key-ids";
import { BackupJsonError } from "@/lib/export/backup-json-scan";
import {
buildBackupPreview,
storedCopyIdentity,
storedPreviewFor,
type BackupPreview,
} from "@/lib/export/backup-preview";
import {
readStreamedBackup,
type BackupSource,
type StreamedBackup,
StreamedBackupInvalidError,
} from "@/lib/export/streamed-backup";
import { annotate } from "@/lib/logging/context";
import {
@@ -43,6 +67,114 @@ export const dynamic = "force-dynamic";
type RouteParams = { params: Promise<{ id: string }> };
/** Why a copy could not be summarised, as the route answers it. */
type Refusal = { status: number; message: string; code?: string };
type ReadOutcome = { preview: BackupPreview } | { refusal: Refusal };
/**
* Reads of a copy without a preview that are running now, by copy. A second
* request for the same copy (the dialog opened again while the first read is
* still going) waits for the first instead of reading it all again.
*/
const inFlight = new Map<string, Promise<ReadOutcome>>();
/** Whether a failure is the file itself not being a backup this reads. */
function isPayloadInvalid(err: unknown): boolean {
return (
err instanceof BackupJsonError ||
err instanceof StreamedBackupInvalidError ||
err instanceof ZodError
);
}
/**
* Read a copy whole and work out its preview: the way every preview was
* taken before previews were stored. Throws for anything that is neither a
* read refusal nor a file that fails the schema, which the handler answers
* as a server fault.
*/
async function readPreview(
backup: StoredBackupRef,
copy: string,
): Promise<ReadOutcome> {
const refused = (err: unknown): ReadOutcome => {
const refusal = storedBackupRefusal(err);
return {
refusal: {
status: refusal.status,
message: refusal.message,
code: refusal.code,
},
};
};
// Opened and read as a stream: a large record's JSON is longer than any
// string V8 can hold (#1031).
let source;
try {
source = await openStoredBackup(prisma, backup);
} catch (err) {
// Same refusal the restore and the download give, for the same reason:
// a copy this instance cannot open is bad stored input, and the preview
// is the first place an operator meets it.
return refused(err);
}
try {
const streamed = await readStreamedBackup(source);
const payload = parseBackupPayload(streamed.raw);
const summary = {
...summarizeBackup(payload),
measurements: streamed.measurementCount,
};
return { preview: buildBackupPreview(copy, summary, streamed.keys) };
} catch (err) {
if (isStoredBackupReadError(err)) return refused(err);
// Only a file that is not a backup this release reads is a 422. Anything
// else is this server failing, not the file, and is answered as such.
if (isPayloadInvalid(err)) {
return {
refusal: {
status: 422,
message: "Backup payload failed schema validation",
},
};
}
throw err;
}
}
/**
* Keep a preview worked out by reading the copy, so the next request answers
* without reading it. Written only while the row still holds the copy it was
* read from; a failure here costs the next request the same read, nothing
* more, so it is noted and not raised.
*/
async function keepPreview(
backup: StoredBackupRef,
preview: BackupPreview,
): Promise<void> {
try {
await prisma.dataBackup.updateMany({
where: {
id: backup.id,
chunkStreamId: backup.chunkStreamId,
chunkCount: backup.chunkCount,
},
data: { preview: preview as unknown as Prisma.InputJsonValue },
});
} catch (err) {
annotate({
action: { name: "admin.backups.summary.keep_failed" },
meta: {
backupId: backup.id,
error: err instanceof Error ? err.name : "unknown",
},
});
}
}
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user: admin } = await requireAdmin();
@@ -53,6 +185,7 @@ export const GET = apiHandler(
where: { id },
select: {
...STORED_BACKUP_SELECT,
preview: true,
createdAt: true,
user: { select: { id: true, username: true } },
},
@@ -61,41 +194,58 @@ export const GET = apiHandler(
throw new HttpError(404, "Backup not found");
}
// Opened and read as a stream: a large record's JSON is longer than any
// string V8 can hold (#1031).
let source: BackupSource;
try {
source = await openStoredBackup(prisma, backup);
} catch (err) {
// Same refusal the restore and the download give, for the same reason:
// a copy this instance cannot open is bad stored input, and the preview
// is the first place an operator meets it.
const refusal = storedBackupRefusal(err);
return apiError(refusal.message, refusal.status, {
errorCode: refusal.code,
});
}
let payload;
let measurementCount: number;
let streamed: StreamedBackup;
try {
streamed = await readStreamedBackup(source);
measurementCount = streamed.measurementCount;
payload = parseBackupPayload(streamed.raw);
} catch (err) {
if (isStoredBackupReadError(err)) {
let preview = storedPreviewFor(backup.preview, backup);
const fromStore = preview !== null;
if (preview) {
// The counts were kept; the copy's pieces are still counted, so a copy
// that lost one since is refused here as the restore would refuse it.
// Tampering inside a piece is found by the restore's own open, before
// it deletes anything.
try {
await checkStoredBackupShape(prisma, backup);
} catch (err) {
if (!isStoredBackupReadError(err)) throw err;
const refusal = storedBackupRefusal(err);
return apiError(refusal.message, refusal.status, {
errorCode: refusal.code,
return refusalResponse({
status: refusal.status,
message: refusal.message,
code: refusal.code,
});
}
return apiError("Backup payload failed schema validation", 422);
}
if (!preview) {
const copy = storedCopyIdentity(backup);
if (copy === null) {
// A row holding both forms, or neither: the open refuses it, with
// the reason.
const outcome = await readPreview(backup, "");
if ("refusal" in outcome) return refusalResponse(outcome.refusal);
throw new Error("A stored backup with no nameable copy was read");
}
const flight = `${backup.id}:${copy}`;
let pending = inFlight.get(flight);
if (!pending) {
pending = readPreview(backup, copy).finally(() => {
inFlight.delete(flight);
});
inFlight.set(flight, pending);
// Kept whatever the verdicts below say: they are taken again on
// every read, and the counts do not change.
void pending.then(
(outcome) =>
"preview" in outcome ? keepPreview(backup, outcome.preview) : null,
() => null,
);
}
const outcome = await pending;
if ("refusal" in outcome) return refusalResponse(outcome.refusal);
preview = outcome.preview;
}
if (!isCompatibleSchemaVersion(payload.schemaVersion)) {
const { summary } = preview;
if (!isCompatibleSchemaVersion(summary.schemaVersion)) {
return apiError(
`Backup schema version ${payload.schemaVersion} is not restorable by this release`,
`Backup schema version ${summary.schemaVersion} is not restorable by this release`,
422,
);
}
@@ -105,9 +255,10 @@ export const GET = apiHandler(
// does not have. The instance settings are left out here, as the restore
// leaves them out unless asked; the restore itself checks them when they
// are asked for.
const keyVerdict = assessBackupKeys(streamed.keys, {
ignoreSections: new Set(["appSettings"]),
});
const keyVerdict = assessBackupKeys(
BackupKeyIdCollector.fromStored(preview.keys),
{ ignoreSections: new Set(["appSettings"]) },
);
const keyProblem = describeBackupKeyProblem(keyVerdict);
if (keyProblem) {
return apiError(keyProblem, 422, {
@@ -116,14 +267,9 @@ export const GET = apiHandler(
});
}
const summary = {
...summarizeBackup(payload),
measurements: measurementCount,
};
annotate({
action: { name: "admin.backups.summary" },
meta: { backupId: id, ownerId: backup.userId },
meta: { backupId: id, ownerId: backup.userId, fromStore },
});
return apiSuccess({
@@ -133,3 +279,11 @@ export const GET = apiHandler(
});
},
);
function refusalResponse(refusal: Refusal) {
return apiError(
refusal.message,
refusal.status,
refusal.code ? { errorCode: refusal.code } : undefined,
);
}
@@ -0,0 +1,245 @@
/**
* The workout-ingest mint (#1054).
*
* The assertion that matters most is not the 201 — it is the exact
* `permissions` array handed to `issueApiToken`. That helper defaults to
* `["*"]` when the property is absent, so an edit that drops one line turns a
* user-facing endpoint into a cookie-equivalent credential factory, and a test
* that only checked the status code would go on passing. The structural guard
* catches a literal `permissions: ["*"` and would not catch the omission, so
* this is where that hole is closed.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/api-handler", () => ({
apiHandler: (fn: unknown) => fn,
requireCookieAuth: vi.fn(),
assertRecentCookieProof: vi.fn(),
}));
vi.mock("@/lib/auth/issue-token", () => ({
issueApiToken: vi.fn(),
}));
vi.mock("@/lib/app-settings", () => ({
isApiGloballyEnabled: vi.fn(),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: vi.fn(() => null),
}));
vi.mock("@/lib/db", () => ({
prisma: { apiToken: { count: vi.fn() } },
}));
import { POST } from "../route";
import { assertRecentCookieProof, requireCookieAuth } from "@/lib/api-handler";
import { issueApiToken } from "@/lib/auth/issue-token";
import { isApiGloballyEnabled } from "@/lib/app-settings";
import { checkRateLimit } from "@/lib/rate-limit";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { WORKOUTS_WRITE_SCOPE } from "@/lib/workouts/scopes";
const USER = { id: "user-1", role: "USER" as const };
const EXPIRES = new Date("2027-08-31T00:00:00.000Z");
function req(body: unknown): Request {
return new Request("https://health.example/api/tokens/workouts", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(requireCookieAuth).mockResolvedValue({
user: USER,
session: { id: "sess-1" },
} as never);
vi.mocked(assertRecentCookieProof).mockResolvedValue(undefined);
vi.mocked(isApiGloballyEnabled).mockResolvedValue(true);
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
vi.mocked(auditLog).mockResolvedValue(undefined as never);
// Below the ceiling by default; the cases that care set their own count.
vi.mocked(prisma.apiToken.count).mockResolvedValue(0 as never);
vi.mocked(issueApiToken).mockResolvedValue({
token: "hlk_" + "a".repeat(64),
expiresAt: EXPIRES,
tokenId: "token-1",
name: "Watch bridge",
} as never);
});
describe("the fresh proof in front of the mint", () => {
it("mints nothing when the session carries no recent proof", async () => {
vi.mocked(assertRecentCookieProof).mockRejectedValue(
new Error("auth.reproof.required"),
);
await expect(POST(req({ name: "Scale" }) as never)).rejects.toThrow(
"auth.reproof.required",
);
expect(issueApiToken).not.toHaveBeenCalled();
});
it("asks about the caller's own session", async () => {
await POST(req({ name: "Scale" }) as never);
expect(assertRecentCookieProof).toHaveBeenCalledWith(
expect.objectContaining({ id: expect.any(String) }),
"sess-1",
);
});
});
describe("what it mints", () => {
it("names exactly the workout scope, and no wildcard", async () => {
const res = await POST(req({ name: "Watch bridge" }) as never);
expect(res.status).toBe(201);
const opts = vi.mocked(issueApiToken).mock.calls[0][0];
// Exact equality, not `toContain`: an extra scope is a widening, and this
// is the assertion standing between the endpoint and one.
expect(opts.permissions).toEqual([WORKOUTS_WRITE_SCOPE]);
expect(opts.userId).toBe(USER.id);
});
it("returns the raw token once, with its name and expiry", async () => {
const res = await POST(req({ name: "Watch bridge" }) as never);
const json = await res.json();
expect(json.data.token).toMatch(/^hlk_[0-9a-f]{64}$/);
expect(json.data.name).toBe("Watch bridge");
expect(json.data.expiresAt).toBeDefined();
});
it("expires by default rather than living forever", async () => {
await POST(req({ name: "Export replay" }) as never);
const opts = vi.mocked(issueApiToken).mock.calls[0][0];
expect(opts.expiresInDays).toBe(365);
});
it("honours an explicit lifetime", async () => {
await POST(req({ name: "Export replay", expiresInDays: 30 }) as never);
expect(vi.mocked(issueApiToken).mock.calls[0][0].expiresInDays).toBe(30);
});
it("audits the mint with the token id and the scope", async () => {
await POST(req({ name: "Export replay" }) as never);
expect(auditLog).toHaveBeenCalledWith(
"tokens.workouts.create",
expect.objectContaining({
userId: USER.id,
details: expect.objectContaining({
tokenId: "token-1",
scope: WORKOUTS_WRITE_SCOPE,
}),
}),
);
});
it("counts only live tokens carrying this scope", async () => {
// The whole shape of the query, asserted rather than assumed. Counting
// every row would let the `["*"]` tokens a login mints consume the budget
// and refuse a mint for a reason the person cannot see; counting revoked
// or expired rows would mean revoking never frees a slot.
await POST(req({ name: "Export replay" }) as never);
const where = vi.mocked(prisma.apiToken.count).mock.calls[0][0]?.where;
expect(where).toMatchObject({
userId: USER.id,
revoked: false,
permissions: { has: WORKOUTS_WRITE_SCOPE },
});
expect(where?.OR).toEqual([
{ expiresAt: null },
{ expiresAt: { gt: expect.any(Date) } },
]);
});
});
describe("when it refuses", () => {
it("403s while the operator has the API switched off", async () => {
vi.mocked(isApiGloballyEnabled).mockResolvedValue(false);
const res = await POST(req({ name: "Export replay" }) as never);
expect(res.status).toBe(403);
expect(issueApiToken).not.toHaveBeenCalled();
});
it("429s past the mint rate limit, without minting", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: false } as never);
const res = await POST(req({ name: "Export replay" }) as never);
expect(res.status).toBe(429);
expect(issueApiToken).not.toHaveBeenCalled();
});
it("422s on a missing name", async () => {
const res = await POST(req({}) as never);
expect(res.status).toBe(422);
expect(issueApiToken).not.toHaveBeenCalled();
});
it("422s on a lifetime outside the permitted band", async () => {
const res = await POST(
req({ name: "Export replay", expiresInDays: 4000 }) as never,
);
expect(res.status).toBe(422);
expect(issueApiToken).not.toHaveBeenCalled();
});
it("409s at the live-token ceiling, without minting", async () => {
vi.mocked(prisma.apiToken.count).mockResolvedValue(10 as never);
const res = await POST(req({ name: "Export replay" }) as never);
// 409, not 429: a conflict with the account's current state, whose remedy
// is revoking rather than waiting. A 429 would say the opposite.
expect(res.status).toBe(409);
expect((await res.json()).meta?.errorCode).toBe(
"tokens.workouts.ceiling_reached",
);
expect(issueApiToken).not.toHaveBeenCalled();
});
it("still mints on the last free slot", async () => {
// The off-by-one that would make the ceiling nine. Worth pinning, because
// `>=` versus `>` is invisible in review and only one of them is right.
vi.mocked(prisma.apiToken.count).mockResolvedValue(9 as never);
const res = await POST(req({ name: "Export replay" }) as never);
expect(res.status).toBe(201);
});
it("checks the ceiling only after the body validates", async () => {
// A malformed request should hear about the malformation, not the
// ceiling — and should not spend a query establishing it.
vi.mocked(prisma.apiToken.count).mockResolvedValue(10 as never);
const res = await POST(req({}) as never);
expect(res.status).toBe(422);
expect(prisma.apiToken.count).not.toHaveBeenCalled();
});
it("ignores a scope the body tries to smuggle in", async () => {
// There is no `scope` field to validate, so an unknown key is simply not
// read — the permission array is a literal either way. Asserted because
// "the field does not exist" is the security property, and a schema change
// that added one would silently make this body meaningful.
await POST(
req({
name: "Export replay",
scope: "read_write",
permissions: ["*"],
}) as never,
);
expect(vi.mocked(issueApiToken).mock.calls[0][0].permissions).toEqual([
WORKOUTS_WRITE_SCOPE,
]);
});
});
+135
View File
@@ -0,0 +1,135 @@
/**
* `POST /api/tokens/workouts` — mint a Bearer that can push workouts into the
* holder's own record and do nothing else (#1054).
*
* The door a workout bridge pushes through: a watch vendor's sync relayed by a
* small service, or a script replaying an exported history. It is the
* measurement-ingest mint (`../measurements/route.ts`) copied for a third
* scope, and every argument made there holds here — read that file for the
* reasoning behind each guard. In short:
*
* - `permissions` is a literal, so no request shape reaches it;
* - `workouts:write` is accepted by `POST /api/workouts/batch` alone, on the
* holder's own record; the rows it writes carry `EXTERNAL` provenance, and
* it cannot list or read a workout back, reach any other route, or mint
* another token;
* - minting takes a cookie session, because a short-lived native access token
* must not be able to leave behind a credential that lives a year;
* - ten mints a minute and ten live tokens bound a runaway loop.
*
* No listing and no revoke of its own: `GET /api/tokens` lists every token
* with its permissions and `DELETE /api/tokens/[id]` revokes one.
*/
import { NextRequest } from "next/server";
import {
apiHandler,
assertRecentCookieProof,
requireCookieAuth,
} from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { issueApiToken } from "@/lib/auth/issue-token";
import { isApiGloballyEnabled } from "@/lib/app-settings";
import { checkRateLimit } from "@/lib/rate-limit";
import { WORKOUTS_WRITE_SCOPE } from "@/lib/workouts/scopes";
import { createWorkoutTokenSchema } from "@/lib/validations/tokens";
/**
* Days a token lives when the caller names no lifetime. A year, like the
* measurement token: it is pasted into a bridge that runs unattended, where a
* quiet expiry surfaces as "my runs stopped showing up sometime in the spring".
*/
const DEFAULT_EXPIRY_DAYS = 365;
/** Mints per user per minute. */
const MINT_RATE_LIMIT_MAX = 10;
const MINT_RATE_LIMIT_WINDOW_MS = 60 * 1000;
/** Live workout tokens an account may hold at once; catches a loop. */
const MAX_LIVE_TOKENS = 10;
export const POST = apiHandler(async (request: NextRequest) => {
const { user, session } = await requireCookieAuth();
// And a fresh proof on top of the session: the token lives far longer than
// a stolen session would, so minting one needs the person, not just the
// browser.
await assertRecentCookieProof(user, session.id);
annotate({ action: { name: "tokens.workouts.create" } });
if (!(await isApiGloballyEnabled())) {
return apiError("API is globally disabled", 403);
}
const rl = await checkRateLimit(
`tokens:workouts:mint:${user.id}`,
MINT_RATE_LIMIT_MAX,
MINT_RATE_LIMIT_WINDOW_MS,
);
if (!rl.allowed) {
return apiError("Too many token mints, try again later", 429);
}
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = createWorkoutTokenSchema.safeParse(body);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422);
}
// Counted live and per scope, after validation and before the create; the
// count-then-create race is bounded by the mint bucket above. See the
// measurement mint for the full argument.
const live = await prisma.apiToken.count({
where: {
userId: user.id,
revoked: false,
permissions: { has: WORKOUTS_WRITE_SCOPE },
OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }],
},
});
if (live >= MAX_LIVE_TOKENS) {
annotate({
action: { name: "tokens.workouts.create" },
meta: { outcome: "ceiling_reached", live_token_count: live },
});
return apiError(
`You already have ${MAX_LIVE_TOKENS} workout tokens. Revoke one before creating another.`,
409,
{ errorCode: "tokens.workouts.ceiling_reached" },
);
}
const issued = await issueApiToken({
userId: user.id,
name: parsed.data.name,
// A literal, never spread and never derived from the body. `issueApiToken`
// defaults to `["*"]` when this property is absent, which is why the unit
// suite asserts this array and not merely the 201.
permissions: [WORKOUTS_WRITE_SCOPE],
expiresInDays: parsed.data.expiresInDays ?? DEFAULT_EXPIRY_DAYS,
});
await auditLog("tokens.workouts.create", {
userId: user.id,
ipAddress: getClientIp(request),
details: { tokenId: issued.tokenId, scope: WORKOUTS_WRITE_SCOPE },
});
// The raw token, once. It is stored as an HMAC and no path re-reveals it.
return apiSuccess(
{ token: issued.token, name: issued.name, expiresAt: issued.expiresAt },
201,
);
});
@@ -0,0 +1,265 @@
/**
* Who may call `POST /api/workouts/batch` (#1054).
*
* The route names `workouts:write`, so the set of credentials it admits is:
* a cookie session, a wildcard (`["*"]`) token, and a narrow token carrying
* exactly that scope. Every other narrow token — `measurements:write`
* included, however adjacent it looks — is refused 403 by the fail-closed
* default.
*
* The real `requireAuth` and the real `resolveBearerToken` run here; only the
* token row lookup is stubbed, so the admission decision under test is the
* production one rather than a re-implementation of it.
*
* The second half pins what a scoped caller writes: its rows are `EXTERNAL`,
* resolved from the credential, and a body naming any source is refused 422
* rather than relabelled. A session and a wildcard token keep the source they
* send.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
apiToken: { findUnique: vi.fn(), update: vi.fn() },
user: { findUnique: vi.fn() },
workout: {
findMany: vi.fn(),
createManyAndReturn: vi.fn(),
},
workoutRoute: { createMany: vi.fn() },
workoutSamples: { createManyAndReturn: vi.fn() },
$transaction: vi.fn(async (fn: unknown) => {
if (typeof fn === "function") {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (fn as any)(prisma as unknown as { workout: unknown });
}
}),
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
vi.mock("@/lib/jobs/pr-detection", () => ({
enqueuePrDetection: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/arrivals/emit-shared", () => ({
emitDataArrival: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../batch/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { headers } from "next/headers";
import { checkRateLimit } from "@/lib/rate-limit";
import { auditLog } from "@/lib/auth/audit";
import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes";
import { DOCUMENTS_WRITE_SCOPE } from "@/lib/documents/scopes";
import { WORKOUTS_WRITE_SCOPE } from "@/lib/workouts/scopes";
const USER = {
id: "user-1",
username: "tester",
role: "USER" as const,
sourcePriorityJson: null,
};
const TOKEN = "hlk_" + "c".repeat(64);
function makeRequest(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/workouts/batch", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
function workout(
externalId: string,
overrides: Record<string, unknown> = {},
): Record<string, unknown> {
return {
sportType: "running",
startedAt: "2026-09-20T06:30:00.000Z",
endedAt: "2026-09-20T07:15:00.000Z",
externalId,
...overrides,
};
}
/** Present `TOKEN` as a Bearer whose row carries `permissions`. */
function armToken(permissions: string[]) {
vi.mocked(getSession).mockResolvedValue(null as never);
vi.mocked(headers).mockResolvedValue({
get: (name: string) =>
name.toLowerCase() === "authorization" ? `Bearer ${TOKEN}` : null,
} as never);
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
id: "tok-1",
userId: USER.id,
permissions,
revoked: false,
expiresAt: new Date(Date.now() + 86_400_000),
} as never);
}
function armSession() {
vi.mocked(getSession).mockResolvedValue({
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: USER,
} as never);
}
/** The rows the route handed to `INSERT ... RETURNING`. */
function insertedRows(): Array<Record<string, unknown>> {
return vi
.mocked(prisma.workout.createManyAndReturn)
.mock.calls.flatMap(([args]) => {
const data = (args as { data: unknown }).data;
return (Array.isArray(data) ? data : [data]) as Array<
Record<string, unknown>
>;
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.stubEnv("API_TOKEN_HMAC_KEY", "k".repeat(64));
vi.mocked(headers).mockResolvedValue({ get: () => null } as never);
vi.mocked(getSession).mockResolvedValue(null as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
limit: 60,
remaining: 60,
resetAt: Date.now() + 60_000,
});
vi.mocked(prisma.apiToken.update).mockResolvedValue({} as never);
// The refusal path chains `.catch` on the audit write, so the reset mock
// has to hand back a promise again.
vi.mocked(auditLog).mockResolvedValue(undefined as never);
// One row serves both lookups: the resolver's user fetch and the route's
// source-priority read.
vi.mocked(prisma.user.findUnique).mockResolvedValue(USER as never);
vi.mocked(prisma.workout.findMany).mockResolvedValue([]);
vi.mocked(prisma.workout.createManyAndReturn).mockImplementation(
(async (args: { data: Array<Record<string, unknown>> }) =>
args.data.map((row, index) => ({
id: `w-${index}`,
source: row.source,
externalId: row.externalId,
}))) as never,
);
});
afterEach(() => {
vi.unstubAllEnvs();
});
describe("POST /api/workouts/batch — which credentials it admits", () => {
it("admits a narrow workouts:write token", async () => {
armToken([WORKOUTS_WRITE_SCOPE]);
const res = await POST(makeRequest({ workouts: [workout("s-1")] }));
expect(res.status).toBe(200);
const body = await res.json();
expect(body.data.inserted).toBe(1);
});
it("still admits a cookie session", async () => {
armSession();
const res = await POST(
makeRequest({ workouts: [workout("s-1", { source: "APPLE_HEALTH" })] }),
);
expect(res.status).toBe(200);
});
it("still admits a wildcard token", async () => {
armToken(["*"]);
const res = await POST(
makeRequest({ workouts: [workout("s-1", { source: "APPLE_HEALTH" })] }),
);
expect(res.status).toBe(200);
});
it.each([
["measurements:write", [MEASUREMENTS_WRITE_SCOPE]],
["documents:write", [DOCUMENTS_WRITE_SCOPE]],
["medication:ingest", ["medication:ingest", "medication:m-1:ingest"]],
["health:read", ["health:read"]],
])("refuses a %s token with 403 and writes nothing", async (_, perms) => {
armToken(perms);
const res = await POST(makeRequest({ workouts: [workout("s-1")] }));
expect(res.status).toBe(403);
expect(prisma.workout.createManyAndReturn).not.toHaveBeenCalled();
});
});
describe("POST /api/workouts/batch — what a scoped caller writes", () => {
it("attributes its rows to EXTERNAL", async () => {
armToken([WORKOUTS_WRITE_SCOPE]);
const res = await POST(
makeRequest({ workouts: [workout("s-1"), workout("s-2")] }),
);
expect(res.status).toBe(200);
const rows = insertedRows();
expect(rows).toHaveLength(2);
expect(rows.map((r) => r.source)).toEqual(["EXTERNAL", "EXTERNAL"]);
});
it("probes for duplicates in the EXTERNAL namespace", async () => {
armToken([WORKOUTS_WRITE_SCOPE]);
await POST(makeRequest({ workouts: [workout("s-1")] }));
const probe = vi
.mocked(prisma.workout.findMany)
.mock.calls.map(([args]) => args as { where?: { OR?: unknown } })
.find((args) => args.where?.OR !== undefined);
expect(probe?.where?.OR).toEqual([
{ source: "EXTERNAL", externalId: "s-1" },
]);
});
it.each(["APPLE_HEALTH", "MANUAL"])(
"refuses a body naming %s with 422 and writes nothing",
async (source) => {
armToken([WORKOUTS_WRITE_SCOPE]);
const res = await POST(
makeRequest({
workouts: [workout("s-1"), workout("s-2", { source })],
}),
);
expect(res.status).toBe(422);
const body = await res.json();
expect(body.meta?.errorCode).toBe("workout.batch.source_not_permitted");
expect(prisma.workout.createManyAndReturn).not.toHaveBeenCalled();
},
);
it("leaves the source a wildcard caller sends alone", async () => {
armToken(["*"]);
await POST(
makeRequest({ workouts: [workout("s-1", { source: "APPLE_HEALTH" })] }),
);
expect(insertedRows().map((r) => r.source)).toEqual(["APPLE_HEALTH"]);
});
it("leaves the source a session sends alone", async () => {
armSession();
await POST(
makeRequest({ workouts: [workout("s-1", { source: "MANUAL" })] }),
);
expect(insertedRows().map((r) => r.source)).toEqual(["MANUAL"]);
});
});
+67 -8
View File
@@ -1,7 +1,7 @@
/**
* `POST /api/workouts/batch` — typed workout ingest with nested route.
*
* Consumers (v1.5):
* Consumers:
* 1. The native iOS app draining its HealthKit observer queue —
* maps `HKWorkout` + `HKWorkoutRoute` samples into the request
* shape locked by `createBatchWorkoutSchema` in
@@ -15,12 +15,18 @@
* (MANUAL / APPLE_HEALTH), so this server path must write its
* `WITHINGS`-sourced rows directly via `prisma.workout.upsert`
* (as the WHOOP / Fitbit syncs already do), not through this route.
* 3. A third-party bridge holding a narrow `workouts:write` token
* (#1054) — a watch vendor's sync relayed by a small service. Its
* rows are attributed `EXTERNAL`, resolved from the credential; see
* the source guard in the handler.
*
* Cross-cutting concerns mirror `/api/measurements/batch` so the iOS
* sync engine can re-use the same retry / cursor plumbing:
* - `requireAuth()` — cookie + Bearer (narrow-scope-token safe per
* v1.4.25 W10 fix-C: when the route declares no scope, any
* authenticated token passes).
* - `requireAuth` naming `WORKOUTS_WRITE_SCOPE` — a cookie session, a
* wildcard (`["*"]`) token, or a narrow `workouts:write` token. Every
* other narrow token is refused 403: under the fail-closed default a
* route admits exactly the scope it names, so a `measurements:write`
* credential does not reach this route.
* - `withIdempotency` — `Idempotency-Key` header replays the cached
* response on retry (24h window).
* - `checkRateLimit("workouts:batch:${user.id}", 60, 60s)` — same
@@ -59,7 +65,7 @@
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, isScopedCredential, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
@@ -85,6 +91,8 @@ import {
type UnstableExternalIdShape,
} from "@/lib/validations/external-id";
import { dedupeWorkoutBatch } from "@/lib/workouts/canonical-rows";
import { WORKOUTS_WRITE_SCOPE } from "@/lib/workouts/scopes";
import { EXTERNAL_SOURCE } from "@/lib/measurements/external-source";
import { encryptRouteGeometry } from "@/lib/workouts/route-geometry-cipher";
import { Prisma, type MeasurementSource } from "@/generated/prisma/client";
@@ -169,7 +177,17 @@ async function emitWorkoutArrivals(
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBatch));
async function postBatch(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
// Declares the workout-ingest scope, so a narrow `workouts:write` token
// reaches this route alongside cookie sessions and the wildcard tokens a
// native login mints. The only route that names it. `requireAuth` refuses
// any acting-account carrier for every credential shape, so the scoped token
// is confined to its owner's own record without an arm of its own here.
const auth = await requireAuth(WORKOUTS_WRITE_SCOPE);
const { user } = auth;
// A scoped credential is a bridge, not the native client: the source of
// its rows is resolved from the credential below rather than from the body.
const scoped = isScopedCredential(auth);
// v1.4.43 W9 — resolve the per-user source-priority blob once at
// the start of the handler so the write-time canonical-row picker
@@ -268,6 +286,27 @@ async function postBatch(request: NextRequest): Promise<Response> {
});
}
// A scoped credential may attribute nothing: its rows are `EXTERNAL`,
// resolved from the credential. Refused loudly rather than relabelled, the
// measurement batch's rule and for its reasons. `APPLE_HEALTH` is half the
// `(userId, source, externalId)` dedup key the phone writes into and ranks
// first in the cross-source picker, so a bridge naming it would land in the
// phone's namespace and could shadow the phone's own copy of a session;
// `MANUAL` would make a bridged workout indistinguishable from one entered
// by hand. Checked on the raw body because the schema defaults an absent
// `source` to `MANUAL`, which would make "named" and "omitted" look alike.
if (scoped && namesAnySource(rawBody)) {
annotate({
action: { name: "workout.batch.ingest" },
meta: { outcome: "source_not_permitted" },
});
return apiError(
"This credential resolves the source itself; omit the field",
422,
{ errorCode: "workout.batch.source_not_permitted" },
);
}
const { workouts } = parsed.data;
const refusedSampleIndices = new Set(parsed.refusedSampleIndices);
@@ -359,7 +398,11 @@ async function postBatch(request: NextRequest): Promise<Response> {
stepCount: w.stepCount ?? null,
elevationM: w.elevationM ?? null,
pauseDurationSec: w.pauseDurationSec ?? null,
source: w.source,
// `EXTERNAL` under a scoped credential, and nothing else: the guard
// above refused any body that named a source, so nothing asserted is
// discarded here. It also moves the dedup key into its own namespace,
// so a bridge's ids can never collide with the phone's.
source: scoped ? EXTERNAL_SOURCE : w.source,
externalId: w.externalId ?? null,
externalSourceVersion: w.externalSourceVersion ?? null,
metadata: w.metadata
@@ -373,7 +416,10 @@ async function postBatch(request: NextRequest): Promise<Response> {
route,
samples,
dedupKey: w.externalId
? { source: w.source, externalId: w.externalId }
? {
source: scoped ? EXTERNAL_SOURCE : w.source,
externalId: w.externalId,
}
: null,
},
];
@@ -719,3 +765,16 @@ async function postBatch(request: NextRequest): Promise<Response> {
entries: results,
});
}
/** Whether any entry of a raw batch body names a `source` at all. */
function namesAnySource(rawBody: unknown): boolean {
if (typeof rawBody !== "object" || rawBody === null) return false;
const workouts = (rawBody as { workouts?: unknown }).workouts;
if (!Array.isArray(workouts)) return false;
return workouts.some(
(w) =>
typeof w === "object" &&
w !== null &&
(w as { source?: unknown }).source !== undefined,
);
}