mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Merge branch 'main' into fix/v1396-small-fixes
This commit is contained in:
39 files changed
+2981
-155
No files matched your search
@@ -152,6 +152,9 @@ describe("T3 — the mint sites are frozen", () => {
|
||||
// Document upload from another system (#1038). Same shape as the
|
||||
// measurement mint: a literal, one scope, one route that accepts it.
|
||||
"app/api/tokens/documents/route.ts": "[DOCUMENTS_WRITE_SCOPE]",
|
||||
// Workout ingest from a bridge (#1054). Same shape again: a literal, one
|
||||
// scope, one route that accepts it.
|
||||
"app/api/tokens/workouts/route.ts": "[WORKOUTS_WRITE_SCOPE]",
|
||||
// MCP, audience-bound to `/mcp`. `health:write` requires explicit consent.
|
||||
"app/api/mcp/tokens/route.ts": "SCOPE_HEALTH_READ / SCOPE_HEALTH_WRITE",
|
||||
"app/api/mcp/oauth/token/route.ts":
|
||||
@@ -179,6 +182,7 @@ describe("T3 — the mint sites are frozen", () => {
|
||||
"app/api/medications/[id]/api-endpoint/route.ts",
|
||||
"app/api/tokens/measurements/route.ts",
|
||||
"app/api/tokens/documents/route.ts",
|
||||
"app/api/tokens/workouts/route.ts",
|
||||
"app/api/mcp/tokens/route.ts",
|
||||
"app/api/mcp/oauth/token/route.ts",
|
||||
];
|
||||
@@ -234,6 +238,7 @@ describe("T4 — declared scopes are exported constants, never string literals",
|
||||
"DOCUMENTS_WRITE_SCOPE",
|
||||
"FHIR_READ_SCOPE",
|
||||
"MEASUREMENTS_WRITE_SCOPE",
|
||||
"WORKOUTS_WRITE_SCOPE",
|
||||
];
|
||||
const used = new Set<string>();
|
||||
for (const rel of sourceFiles()) {
|
||||
@@ -284,6 +289,27 @@ describe("T4b — `documents:write` opens exactly two doors", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("T4c — `workouts:write` opens exactly one door", () => {
|
||||
it("only the workout batch declares the scope, and only on its POST", () => {
|
||||
// The set of files naming the scope IS its reach. The batch ingest and
|
||||
// nothing else: not the workout list or detail, which would hand a
|
||||
// bridge the history it has no business reading back.
|
||||
const declaring = filesMatching(
|
||||
/requireAuth\(\s*WORKOUTS_WRITE_SCOPE\s*\)|requireRecordAuth\([^)]*\bscope:\s*WORKOUTS_WRITE_SCOPE\b/,
|
||||
);
|
||||
expect(declaring).toEqual(["app/api/workouts/batch/route.ts"]);
|
||||
const batch = read("app/api/workouts/batch/route.ts");
|
||||
expect(
|
||||
batch.match(/requireAuth\(\s*WORKOUTS_WRITE_SCOPE\s*\)/g),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
[...batch.matchAll(/export const (GET|POST|PUT|PATCH|DELETE)\b/g)].map(
|
||||
(m) => m[1],
|
||||
),
|
||||
).toEqual(["POST"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("T5 — a delegable route naming a Bearer scope is frozen", () => {
|
||||
/**
|
||||
* Routes that admit a narrow token on a DELEGABLE surface, and the scope each
|
||||
|
||||
@@ -114,6 +114,7 @@ const COOKIE_PROOF_ROUTES = [
|
||||
"app/api/auth/reproof/route.ts",
|
||||
"app/api/tokens/documents/route.ts",
|
||||
"app/api/tokens/measurements/route.ts",
|
||||
"app/api/tokens/workouts/route.ts",
|
||||
].sort();
|
||||
|
||||
describe("the recent-proof gate", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* The restore dialog's contents box asks once and says honestly why it has
|
||||
* nothing to show.
|
||||
*
|
||||
* A copy from an earlier version is read whole on the server, which took two
|
||||
* minutes for a large record; the dialog stopped waiting, retried, and the
|
||||
* server read the whole copy a second time (#1031). Pinned here: the preview
|
||||
* query is never retried and carries its own timeout, and a timeout reads as
|
||||
* "still being read", not as "could not be read".
|
||||
*
|
||||
* Mutation check: drop `retry: false` and the first case goes red; classify a
|
||||
* timeout as unavailable and the second goes red.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { ApiError } from "@/lib/api/api-fetch";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
queries: [] as Array<Record<string, unknown>>,
|
||||
apiGet: vi.fn(async () => ({ summary: {} })),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/admin/backups",
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
vi.mock("@/lib/api/api-fetch", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/api/api-fetch")>();
|
||||
return { ...actual, apiGet: mocks.apiGet };
|
||||
});
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: (options: Record<string, unknown>) => {
|
||||
mocks.queries.push(options);
|
||||
return {
|
||||
data: {
|
||||
rows: [
|
||||
{
|
||||
id: "b1",
|
||||
userId: "u1",
|
||||
username: "self-hoster",
|
||||
type: "WEEKLY_AUTO",
|
||||
sizeBytes: 1024,
|
||||
createdAt: "2026-09-01T03:00:00.000Z",
|
||||
},
|
||||
],
|
||||
retentionDays: 30,
|
||||
schedule: {
|
||||
lastSuccessAt: "2026-09-01T03:00:00.000Z",
|
||||
lastSuccessAgeDays: 2,
|
||||
staleAfterDays: 10,
|
||||
stale: false,
|
||||
lastRun: null,
|
||||
lastRunFailed: false,
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
};
|
||||
},
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
useMutation: () => ({
|
||||
mutate: vi.fn(),
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
variables: undefined,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: "u1", username: "testuser", role: "ADMIN" },
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { BackupsSection, restorePreviewFailure } from "../backups-section";
|
||||
|
||||
describe("restore preview query", () => {
|
||||
it("is never retried and waits a bounded time", async () => {
|
||||
renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<BackupsSection />
|
||||
</I18nProvider>,
|
||||
);
|
||||
const key = JSON.stringify(queryKeys.adminBackupSummary("b1"));
|
||||
const preview = mocks.queries.find(
|
||||
(options) => JSON.stringify(options.queryKey) === key,
|
||||
);
|
||||
expect(preview).toBeDefined();
|
||||
expect(preview!.retry).toBe(false);
|
||||
|
||||
await (preview!.queryFn as () => Promise<unknown>)();
|
||||
const [path, init] = mocks.apiGet.mock.calls[0] as unknown as [
|
||||
string,
|
||||
RequestInit,
|
||||
];
|
||||
expect(path).toBe("/api/admin/backups/b1/summary");
|
||||
expect(init.signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
});
|
||||
|
||||
describe("restorePreviewFailure", () => {
|
||||
it("reads a timeout as a copy still being read", () => {
|
||||
const timeout = new DOMException("signal timed out", "TimeoutError");
|
||||
expect(restorePreviewFailure(timeout)).toBe("stillReading");
|
||||
});
|
||||
|
||||
it("names a missing key", () => {
|
||||
expect(
|
||||
restorePreviewFailure(
|
||||
new ApiError("key", 422, {
|
||||
errorCode: "backup.key.missing",
|
||||
keyIds: ["v1"],
|
||||
}),
|
||||
),
|
||||
).toBe("keyMissing");
|
||||
});
|
||||
|
||||
it("calls everything else unavailable", () => {
|
||||
expect(restorePreviewFailure(new ApiError("boom", 500))).toBe(
|
||||
"unavailable",
|
||||
);
|
||||
expect(restorePreviewFailure(new TypeError("offline"))).toBe("unavailable");
|
||||
});
|
||||
});
|
||||
@@ -90,6 +90,39 @@ import {
|
||||
* Mirrors the wipe dialog's pattern but adds the typed gate because the
|
||||
* blast radius is bigger (re-creates rows, not just deletes).
|
||||
*/
|
||||
/**
|
||||
* How long the restore dialog waits for the contents of a copy. A copy's
|
||||
* counts are kept with it and come back at once; this only runs out on a copy
|
||||
* from an earlier version that the server is still reading whole, and stays
|
||||
* under the minute a reverse proxy commonly allows a request.
|
||||
*/
|
||||
const PREVIEW_TIMEOUT_MS = 55_000;
|
||||
|
||||
/**
|
||||
* What the restore dialog says when the contents did not come back: the key
|
||||
* the copy needs and this server lacks, a copy from an earlier version still
|
||||
* being read when the dialog stopped waiting, or anything else.
|
||||
*/
|
||||
export function restorePreviewFailure(
|
||||
error: unknown,
|
||||
): "keyMissing" | "stillReading" | "unavailable" {
|
||||
if (
|
||||
error instanceof ApiError &&
|
||||
error.meta?.errorCode === "backup.key.missing"
|
||||
) {
|
||||
return "keyMissing";
|
||||
}
|
||||
// `AbortSignal.timeout` rejects the fetch with a DOMException of this name.
|
||||
if (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
(error as { name?: unknown }).name === "TimeoutError"
|
||||
) {
|
||||
return "stillReading";
|
||||
}
|
||||
return "unavailable";
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.37.20 — the sections a restore preview names, in render order. Each
|
||||
* entry sums one or more `BackupSummary` counters under one human label;
|
||||
@@ -151,18 +184,25 @@ function RestoreRowDialog({
|
||||
const [withInstanceSettings, setWithInstanceSettings] = useState(false);
|
||||
|
||||
// v1.37.20 — restore preview: fetch what the file contains the moment the
|
||||
// dialog opens, so the typed confirmation is an informed one. Derived from
|
||||
// the same decrypt + schema path the restore itself runs. A failed preview
|
||||
// never blocks the restore — it states its own absence instead.
|
||||
// dialog opens, so the typed confirmation is an informed one. A failed
|
||||
// preview never blocks the restore — it states its own absence instead.
|
||||
//
|
||||
// Never retried. The server answers from counts kept with the copy, at
|
||||
// once; only a copy from an earlier version is read whole, and that read
|
||||
// goes on (and is kept) after this request gives up. A retry would only
|
||||
// start the same read a second time (#1031).
|
||||
const preview = useQuery({
|
||||
queryKey: queryKeys.adminBackupSummary(row.id),
|
||||
queryFn: () =>
|
||||
apiGet<{ summary: Record<string, unknown> }>(
|
||||
`/api/admin/backups/${row.id}/summary`,
|
||||
{ signal: AbortSignal.timeout(PREVIEW_TIMEOUT_MS) },
|
||||
),
|
||||
enabled: open,
|
||||
staleTime: 60_000,
|
||||
retry: false,
|
||||
});
|
||||
const previewFailure = restorePreviewFailure(preview.error);
|
||||
const numericSummary: Record<string, number> = {};
|
||||
if (preview.data?.summary) {
|
||||
for (const [key, value] of Object.entries(preview.data.summary)) {
|
||||
@@ -239,17 +279,21 @@ function RestoreRowDialog({
|
||||
</p>
|
||||
)}
|
||||
{preview.isError &&
|
||||
(preview.error instanceof ApiError &&
|
||||
preview.error.meta?.errorCode === "backup.key.missing" ? (
|
||||
(previewFailure === "keyMissing" &&
|
||||
preview.error instanceof ApiError ? (
|
||||
// The one preview failure the restore would repeat: say which
|
||||
// key, so the operator can put it back before trying.
|
||||
<p role="alert" className="text-destructive">
|
||||
{t("admin.section.backups.previewKeyMissing", {
|
||||
keys: Array.isArray(preview.error.meta.keyIds)
|
||||
keys: Array.isArray(preview.error.meta?.keyIds)
|
||||
? preview.error.meta.keyIds.map(String).join(", ")
|
||||
: "",
|
||||
})}
|
||||
</p>
|
||||
) : previewFailure === "stillReading" ? (
|
||||
<p className="text-muted-foreground">
|
||||
{t("admin.section.backups.previewStillReading")}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground">
|
||||
{t("admin.section.backups.previewUnavailable")}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
Activity,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Copy,
|
||||
@@ -62,6 +63,7 @@ export function ApiSection() {
|
||||
<div className="space-y-6">
|
||||
<ApiEndpointsCard />
|
||||
<MeasurementTokensCard />
|
||||
<WorkoutTokensCard />
|
||||
<DocumentTokensCard />
|
||||
<ApiTokensCard />
|
||||
</div>
|
||||
@@ -95,6 +97,29 @@ function MeasurementTokensCard() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a token a workout bridge pushes through (#1054): a watch vendor's sync
|
||||
* relayed by a small service, or a script replaying an export. Same card,
|
||||
* same no-list reasoning as the measurement one above.
|
||||
*/
|
||||
function WorkoutTokensCard() {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<IngestTokenCard
|
||||
endpoint="/api/tokens/workouts"
|
||||
icon={Activity}
|
||||
slot="settings-workouts-token-created"
|
||||
copy={{
|
||||
title: t("settings.workoutsToken.title"),
|
||||
description: t("settings.workoutsToken.description"),
|
||||
detail: t("settings.workoutsToken.detail"),
|
||||
scopeNote: t("settings.workoutsToken.scopeNote"),
|
||||
createFailed: t("settings.workoutsToken.createFailed"),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a token another document system uploads through (#1038): a Paperless
|
||||
* workflow or the import script. Same card, same no-list reasoning as the
|
||||
@@ -125,7 +150,10 @@ function IngestTokenCard({
|
||||
slot,
|
||||
copy,
|
||||
}: {
|
||||
endpoint: "/api/tokens/measurements" | "/api/tokens/documents";
|
||||
endpoint:
|
||||
| "/api/tokens/measurements"
|
||||
| "/api/tokens/workouts"
|
||||
| "/api/tokens/documents";
|
||||
icon: LucideIcon;
|
||||
slot: string;
|
||||
copy: {
|
||||
@@ -296,6 +324,12 @@ function ApiEndpointsCard() {
|
||||
auth: "Authorization: Bearer hlk_...",
|
||||
example: `{ "entries": [ { "hkIdentifier": "...", "value": 1, ... } ] }`,
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/workouts/batch",
|
||||
auth: "Authorization: Bearer hlk_...",
|
||||
example: `{ "workouts": [ { "sportType": "running", "startedAt": "...", "endedAt": "...", "externalId": "..." } ] }`,
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/documents/inbound",
|
||||
|
||||
@@ -71,6 +71,8 @@ describe.each([
|
||||
["components/settings/share-link-create-form.tsx", "/api/share-links"],
|
||||
["components/settings/mcp-section.tsx", "/api/mcp/tokens"],
|
||||
["components/settings/api-section.tsx", "/api/tokens/measurements"],
|
||||
["components/settings/api-section.tsx", "/api/tokens/documents"],
|
||||
["components/settings/api-section.tsx", "/api/tokens/workouts"],
|
||||
["components/admin/backups-section.tsx", "/download"],
|
||||
["components/admin/backups-section.tsx", "/restore"],
|
||||
["components/admin/backups-section.tsx", "/api/admin/backups/upload"],
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* The preview worked out while a copy is written has to say what the preview
|
||||
* route used to derive by reading the stored copy: the same counts, the same
|
||||
* key uses. Held here against that derivation over the same file, fed in
|
||||
* pieces that split values, strings and multi-byte characters.
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { _resetCryptoCacheForTests, encrypt } from "@/lib/crypto";
|
||||
|
||||
import {
|
||||
BackupKeyIdCollector,
|
||||
assessBackupKeys,
|
||||
} from "@/lib/export/backup-key-ids";
|
||||
import {
|
||||
buildBackupPreview,
|
||||
createBackupPreviewScanner,
|
||||
storedCopyIdentity,
|
||||
storedPreviewFor,
|
||||
} from "@/lib/export/backup-preview";
|
||||
import { backupPayloadSchema, summarizeBackup } from "@/lib/validations/backup";
|
||||
|
||||
const ts = "2026-07-19T07:00:00.000Z";
|
||||
/** A bytes-column value holding the string codec under key id `v3`. */
|
||||
const sealedV3 = Buffer.from(`v3.${"QUJD".repeat(20)}`, "utf8").toString(
|
||||
"base64",
|
||||
);
|
||||
|
||||
const FILE = {
|
||||
schemaVersion: "2",
|
||||
exportedAt: ts,
|
||||
userId: "u1",
|
||||
measurements: [1, 2, 3].map((i) => ({
|
||||
id: `m${i}`,
|
||||
type: "WEIGHT",
|
||||
value: 70 + i,
|
||||
unit: "kg",
|
||||
measuredAt: ts,
|
||||
source: "MANUAL",
|
||||
notes: "Grüße, ☕",
|
||||
...(i === 2 ? { notesEncrypted: sealedV3 } : {}),
|
||||
})),
|
||||
intakeEvents: [
|
||||
{ medication: "Example", scheduledFor: ts, source: "WEB" },
|
||||
{ medication: "Example", scheduledFor: ts, source: "WEB" },
|
||||
],
|
||||
moodEntries: [
|
||||
{
|
||||
date: "2026-05-08",
|
||||
mood: "GUT",
|
||||
score: 4,
|
||||
source: "MOODLOG",
|
||||
loggedAt: ts,
|
||||
},
|
||||
],
|
||||
labResults: [{ analyte: "HbA1c", unit: "%", takenAt: ts, source: "MANUAL" }],
|
||||
illnessEpisodes: [
|
||||
{
|
||||
id: "ep-1",
|
||||
label: "Cold",
|
||||
type: "INFECTION",
|
||||
lifecycle: "ACUTE",
|
||||
onsetAt: ts,
|
||||
dayLogs: [
|
||||
{ date: "2026-04-01", symptoms: [] },
|
||||
{ date: "2026-04-02", symptoms: [] },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async function scan(pieces: Array<string | Uint8Array>) {
|
||||
const scanner = createBackupPreviewScanner();
|
||||
for (const piece of pieces) await scanner.feed(piece);
|
||||
return scanner.finish();
|
||||
}
|
||||
|
||||
function split(text: string, size: number): Uint8Array[] {
|
||||
const bytes = Buffer.from(text, "utf8");
|
||||
const out: Uint8Array[] = [];
|
||||
for (let at = 0; at < bytes.length; at += size) {
|
||||
out.push(bytes.subarray(at, at + size));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("createBackupPreviewScanner", () => {
|
||||
const json = JSON.stringify(FILE);
|
||||
// What the preview route derived from a stored copy.
|
||||
const expected = summarizeBackup(backupPayloadSchema.parse(FILE));
|
||||
const expectedKeys = new BackupKeyIdCollector();
|
||||
expectedKeys.visit(FILE);
|
||||
|
||||
it.each([1, 7, 64, 100_000])(
|
||||
"counts what the full read counts, in pieces of %i bytes",
|
||||
async (size) => {
|
||||
const result = await scan(split(json, size));
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.summary).toEqual(expected);
|
||||
expect(result!.summary.measurements).toBe(3);
|
||||
expect(result!.summary.intakeEvents).toBe(2);
|
||||
expect(result!.summary.moodEntries).toBe(1);
|
||||
expect(result!.summary.illnessDayLogs).toBe(2);
|
||||
expect(result!.keys.toStored()).toEqual(expectedKeys.toStored());
|
||||
expect(result!.keys.keyIds()).toEqual(["v3"]);
|
||||
},
|
||||
);
|
||||
|
||||
it("takes text pieces the way the weekly writer hands them over", async () => {
|
||||
const result = await scan([json.slice(0, 50), json.slice(50)]);
|
||||
expect(result!.summary).toEqual(expected);
|
||||
});
|
||||
|
||||
it("answers null for a file that is not a backup, and never holds the writer", async () => {
|
||||
expect(await scan(["{ not json", "more", "and more"])).toBeNull();
|
||||
expect(await scan([JSON.stringify({ schemaVersion: 2 })])).toBeNull();
|
||||
expect(await scan([json.slice(0, 40)])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("storedPreviewFor", () => {
|
||||
const chunked = { data: null, chunkCount: 4, chunkStreamId: "s1" };
|
||||
const preview = () =>
|
||||
buildBackupPreview(
|
||||
storedCopyIdentity(chunked)!,
|
||||
summarizeBackup(backupPayloadSchema.parse(FILE)),
|
||||
new BackupKeyIdCollector(),
|
||||
);
|
||||
|
||||
it("reads a preview of the copy the row holds", () => {
|
||||
const stored = JSON.parse(JSON.stringify(preview()));
|
||||
expect(storedPreviewFor(stored, chunked)?.summary.measurements).toBe(3);
|
||||
});
|
||||
|
||||
it("ignores a preview of another copy", () => {
|
||||
expect(
|
||||
storedPreviewFor(preview(), { ...chunked, chunkStreamId: "s2" }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
storedPreviewFor(preview(), { ...chunked, chunkCount: 5 }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("describes nothing on a row holding both forms, or none", () => {
|
||||
expect(storedPreviewFor(preview(), { ...chunked, data: "x" })).toBeNull();
|
||||
expect(
|
||||
storedPreviewFor(preview(), {
|
||||
data: null,
|
||||
chunkCount: null,
|
||||
chunkStreamId: null,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("ignores a malformed preview", () => {
|
||||
expect(storedPreviewFor({ version: 1 }, chunked)).toBeNull();
|
||||
expect(storedPreviewFor(null, chunked)).toBeNull();
|
||||
});
|
||||
|
||||
it("names a single stored value by its length and head", () => {
|
||||
const a = storedCopyIdentity({
|
||||
data: "v1.aaaa",
|
||||
chunkCount: null,
|
||||
chunkStreamId: null,
|
||||
});
|
||||
const b = storedCopyIdentity({
|
||||
data: "v1.aaab",
|
||||
chunkCount: null,
|
||||
chunkStreamId: null,
|
||||
});
|
||||
expect(a).toMatch(/^value:[0-9a-f]{64}$/);
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BackupKeyIdCollector.toStored / fromStored", () => {
|
||||
const saved = {
|
||||
key: process.env.ENCRYPTION_KEY,
|
||||
keys: process.env.ENCRYPTION_KEYS,
|
||||
active: process.env.ENCRYPTION_ACTIVE_KEY_ID,
|
||||
};
|
||||
function useKeys(keys: Record<string, string>, active: string) {
|
||||
delete process.env.ENCRYPTION_KEY;
|
||||
process.env.ENCRYPTION_KEYS = JSON.stringify(keys);
|
||||
process.env.ENCRYPTION_ACTIVE_KEY_ID = active;
|
||||
_resetCryptoCacheForTests();
|
||||
}
|
||||
afterEach(() => {
|
||||
for (const [name, value] of [
|
||||
["ENCRYPTION_KEY", saved.key],
|
||||
["ENCRYPTION_KEYS", saved.keys],
|
||||
["ENCRYPTION_ACTIVE_KEY_ID", saved.active],
|
||||
] as const) {
|
||||
if (value === undefined) delete process.env[name];
|
||||
else process.env[name] = value;
|
||||
}
|
||||
_resetCryptoCacheForTests();
|
||||
});
|
||||
|
||||
it("keeps what the verdict needs, so it can be taken again later", () => {
|
||||
// Written under `old` and `other` on the host that made the copy.
|
||||
useKeys({ old: "11".repeat(32), other: "22".repeat(32) }, "old");
|
||||
const collector = new BackupKeyIdCollector();
|
||||
collector.add("moodEntries", encrypt("a note"));
|
||||
process.env.ENCRYPTION_ACTIVE_KEY_ID = "other";
|
||||
_resetCryptoCacheForTests();
|
||||
collector.add("labResults", encrypt("another note"));
|
||||
collector.add("appSettings", encrypt("a setting"));
|
||||
const stored = JSON.parse(JSON.stringify(collector.toStored()));
|
||||
const options = { ignoreSections: new Set(["appSettings"]) };
|
||||
|
||||
// Later, on a host that dropped `old` and holds different material
|
||||
// under `other`.
|
||||
useKeys({ other: "33".repeat(32) }, "other");
|
||||
const back = BackupKeyIdCollector.fromStored(stored);
|
||||
const verdict = assessBackupKeys(back, options);
|
||||
expect(verdict.missing).toEqual(["old"]);
|
||||
expect(verdict.unreadable).toEqual(["other"]);
|
||||
expect(verdict).toEqual(assessBackupKeys(collector, options));
|
||||
expect(back.toStored()).toEqual(collector.toStored());
|
||||
});
|
||||
});
|
||||
@@ -185,6 +185,43 @@ export class BackupKeyIdCollector {
|
||||
entries(): ReadonlyMap<string, KeyUse> {
|
||||
return this.uses;
|
||||
}
|
||||
|
||||
/**
|
||||
* What was collected, as plain data, for a copy's stored preview
|
||||
* (`backup-preview.ts`): enough to hold it against this host's keys again
|
||||
* later without reading the copy.
|
||||
*/
|
||||
toStored(): StoredKeyUse[] {
|
||||
return [...this.uses.entries()]
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([keyId, use]) => ({
|
||||
keyId,
|
||||
count: use.count,
|
||||
sections: [...use.sections].sort(),
|
||||
sample: use.sample ? { ...use.sample } : null,
|
||||
}));
|
||||
}
|
||||
|
||||
/** The collector {@link toStored} was taken from. */
|
||||
static fromStored(stored: readonly StoredKeyUse[]): BackupKeyIdCollector {
|
||||
const collector = new BackupKeyIdCollector();
|
||||
for (const entry of stored) {
|
||||
collector.uses.set(entry.keyId, {
|
||||
count: entry.count,
|
||||
sections: new Set(entry.sections),
|
||||
sample: entry.sample ? { ...entry.sample } : null,
|
||||
});
|
||||
}
|
||||
return collector;
|
||||
}
|
||||
}
|
||||
|
||||
/** One key id a copy needs, as {@link BackupKeyIdCollector.toStored} keeps it. */
|
||||
export interface StoredKeyUse {
|
||||
keyId: string;
|
||||
count: number;
|
||||
sections: string[];
|
||||
sample: { value: string; form: InnerCiphertextForm } | null;
|
||||
}
|
||||
|
||||
export interface BackupKeyAssessment {
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* What the restore preview shows for a stored backup, worked out while the
|
||||
* copy is written and kept on its row (`DataBackup.preview`).
|
||||
*
|
||||
* Why. The preview used to read the whole stored copy inside the request that
|
||||
* asked for it: open every piece, parse every measurement, validate the rest.
|
||||
* For an account of 1.8 million readings on slow hardware that took two
|
||||
* minutes, the dialog gave up long before, and the operator was told the
|
||||
* contents could not be read (#1031). The copy passes through this process
|
||||
* once anyway, on its way into the pieces, so the counts are taken there.
|
||||
*
|
||||
* What is kept. The `summarizeBackup` counts of the copy, its schema version,
|
||||
* and the inner key ids with the sections they sit in and one sample value
|
||||
* each. The verdicts are not kept: whether this release can restore the
|
||||
* schema version, and whether this server holds the keys the copy needs, can
|
||||
* both change after the copy was written, so the route takes them again from
|
||||
* these inputs on every read.
|
||||
*
|
||||
* Which copy. A preview names the copy it describes (`copy`), and a reader
|
||||
* ignores one that names another: a copy replaced by a writer that does not
|
||||
* know about previews (an older release, after a downgrade) must not be
|
||||
* described by the numbers of the one it replaced.
|
||||
*/
|
||||
import { createHash } from "node:crypto";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
BackupKeyIdCollector,
|
||||
type StoredKeyUse,
|
||||
} from "@/lib/export/backup-key-ids";
|
||||
import { scanBackupJson } from "@/lib/export/backup-json-scan";
|
||||
import type { StoredBackupRef } from "@/lib/export/stored-backup";
|
||||
import {
|
||||
backupPayloadSchema,
|
||||
summarizeBackup,
|
||||
type BackupSummary,
|
||||
} from "@/lib/validations/backup";
|
||||
|
||||
export interface BackupPreview {
|
||||
version: 1;
|
||||
/** The copy this describes (`storedCopyIdentity`). */
|
||||
copy: string;
|
||||
summary: BackupSummary;
|
||||
keys: StoredKeyUse[];
|
||||
}
|
||||
|
||||
const storedPreviewSchema = z.object({
|
||||
version: z.literal(1),
|
||||
copy: z.string().min(1),
|
||||
summary: z
|
||||
.object({
|
||||
schemaVersion: z.string(),
|
||||
userId: z.string(),
|
||||
exportedAt: z.string(),
|
||||
measurements: z.number(),
|
||||
})
|
||||
.catchall(z.union([z.string(), z.number()])),
|
||||
keys: z.array(
|
||||
z.object({
|
||||
keyId: z.string(),
|
||||
count: z.number().int().nonnegative(),
|
||||
sections: z.array(z.string()),
|
||||
sample: z
|
||||
.object({
|
||||
value: z.string(),
|
||||
form: z.enum(["string", "bytes-string", "binary"]),
|
||||
})
|
||||
.nullable(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
* How a single stored value is told apart from another: its length and its
|
||||
* first characters, which carry the key id and the random nonce of the
|
||||
* envelope. Hashed, so the preview does not repeat the envelope's head.
|
||||
*/
|
||||
export function singleValueIdentity(length: number, head: string): string {
|
||||
return `value:${createHash("sha256").update(`${length}:${head}`).digest("hex")}`;
|
||||
}
|
||||
|
||||
/** How many leading characters of a single stored value the identity reads. */
|
||||
export const SINGLE_VALUE_IDENTITY_HEAD = 128;
|
||||
|
||||
/**
|
||||
* The name of the copy a row holds now, or null for a row nothing may
|
||||
* describe: one holding both forms, or neither.
|
||||
*/
|
||||
export function storedCopyIdentity(
|
||||
backup: Pick<StoredBackupRef, "data" | "chunkCount" | "chunkStreamId">,
|
||||
): string | null {
|
||||
if (backup.data != null && backup.chunkStreamId != null) return null;
|
||||
if (backup.chunkStreamId != null) {
|
||||
return `chunks:${backup.chunkStreamId}:${backup.chunkCount ?? ""}`;
|
||||
}
|
||||
if (backup.data != null) {
|
||||
return singleValueIdentity(
|
||||
backup.data.length,
|
||||
backup.data.slice(0, SINGLE_VALUE_IDENTITY_HEAD),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildBackupPreview(
|
||||
copy: string,
|
||||
summary: BackupSummary,
|
||||
keys: BackupKeyIdCollector,
|
||||
): BackupPreview {
|
||||
return { version: 1, copy, summary, keys: keys.toStored() };
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored preview of a row, when it describes the copy the row holds now;
|
||||
* null when there is none, it is malformed, or it describes another copy.
|
||||
*/
|
||||
export function storedPreviewFor(
|
||||
stored: unknown,
|
||||
backup: Pick<StoredBackupRef, "data" | "chunkCount" | "chunkStreamId">,
|
||||
): BackupPreview | null {
|
||||
const copy = storedCopyIdentity(backup);
|
||||
if (copy === null || stored == null) return null;
|
||||
const parsed = storedPreviewSchema.safeParse(stored);
|
||||
if (!parsed.success || parsed.data.copy !== copy) return null;
|
||||
return parsed.data as unknown as BackupPreview;
|
||||
}
|
||||
|
||||
/**
|
||||
* The sections that grow with a record. Their elements are counted and their
|
||||
* key ids read as they pass, and never kept, so the scan holds no more of the
|
||||
* copy than the writer itself does (`full-backup-stream.ts`).
|
||||
*/
|
||||
const STREAMED = new Set(["measurements", "intakeEvents", "moodEntries"]);
|
||||
|
||||
export interface BackupPreviewScanner {
|
||||
/**
|
||||
* Hand over the next piece of the JSON. Resolves once the scan has taken
|
||||
* it, so a writer never runs more than a piece ahead of the scan.
|
||||
*/
|
||||
feed(piece: string | Uint8Array): Promise<void>;
|
||||
/**
|
||||
* The counts and key uses, once every piece has been fed; null when the
|
||||
* JSON was not a backup the schema accepts. Never rejects: a copy is
|
||||
* stored whether or not its preview could be worked out.
|
||||
*/
|
||||
finish(): Promise<{
|
||||
summary: BackupSummary;
|
||||
keys: BackupKeyIdCollector;
|
||||
} | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Work out a preview from a copy's JSON as it is written. The counts are the
|
||||
* ones the preview route derives from the stored copy (`summarizeBackup` over
|
||||
* the parsed file), the three bulk sections counted rather than parsed.
|
||||
*/
|
||||
export function createBackupPreviewScanner(): BackupPreviewScanner {
|
||||
const queue: Array<{ chunk: Uint8Array; taken: () => void }> = [];
|
||||
let ended = false;
|
||||
let stopped = false;
|
||||
let wake: (() => void) | null = null;
|
||||
const signal = () => {
|
||||
const w = wake;
|
||||
wake = null;
|
||||
w?.();
|
||||
};
|
||||
|
||||
async function* pieces(): AsyncGenerator<Uint8Array> {
|
||||
for (;;) {
|
||||
const next = queue.shift();
|
||||
if (next) {
|
||||
next.taken();
|
||||
yield next.chunk;
|
||||
continue;
|
||||
}
|
||||
if (ended) return;
|
||||
await new Promise<void>((resolve) => {
|
||||
wake = resolve;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const keys = new BackupKeyIdCollector();
|
||||
const result = scanBackupJson(pieces(), {
|
||||
streamKeys: STREAMED,
|
||||
onElement: (key, element) => keys.visit(element, key),
|
||||
})
|
||||
.then(({ document, streamedCounts }) => {
|
||||
keys.visit(document);
|
||||
const parsed = backupPayloadSchema.safeParse(document);
|
||||
if (!parsed.success) return null;
|
||||
const summary: BackupSummary = {
|
||||
...summarizeBackup(parsed.data),
|
||||
measurements: streamedCounts.measurements ?? 0,
|
||||
intakeEvents: streamedCounts.intakeEvents ?? 0,
|
||||
moodEntries: streamedCounts.moodEntries ?? 0,
|
||||
};
|
||||
return { summary, keys };
|
||||
})
|
||||
.catch(() => null)
|
||||
.finally(() => {
|
||||
// A scan that stopped early takes nothing more: release the writer.
|
||||
stopped = true;
|
||||
for (const waiting of queue.splice(0)) waiting.taken();
|
||||
});
|
||||
|
||||
return {
|
||||
feed(piece) {
|
||||
if (stopped) return Promise.resolve();
|
||||
const chunk =
|
||||
typeof piece === "string" ? Buffer.from(piece, "utf8") : piece;
|
||||
return new Promise<void>((taken) => {
|
||||
queue.push({ chunk, taken });
|
||||
signal();
|
||||
});
|
||||
},
|
||||
finish() {
|
||||
ended = true;
|
||||
signal();
|
||||
return result;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -23,7 +23,7 @@
|
||||
* this module exists to remove. A model create is planned once for its shape
|
||||
* and keeps nothing.
|
||||
*/
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { Prisma, type PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
import { isStreamCiphertext, openStreamDecryptor } from "@/lib/crypto";
|
||||
import {
|
||||
@@ -35,6 +35,14 @@ import {
|
||||
type PackBackupOptions,
|
||||
} from "@/lib/export/backup-blob";
|
||||
import { createBackupKeyIdTextScanner } from "@/lib/export/backup-key-ids";
|
||||
import {
|
||||
buildBackupPreview,
|
||||
createBackupPreviewScanner,
|
||||
SINGLE_VALUE_IDENTITY_HEAD,
|
||||
singleValueIdentity,
|
||||
storedCopyIdentity,
|
||||
type BackupPreview,
|
||||
} from "@/lib/export/backup-preview";
|
||||
import {
|
||||
BackupIntegrityError,
|
||||
newChunkStreamId,
|
||||
@@ -118,6 +126,9 @@ export async function storeBackupBlob(
|
||||
// what is inside them, so this is what tells an operator how long a
|
||||
// retired key is still needed (`GET /api/admin/encryption/status`).
|
||||
const keyScanner = createBackupKeyIdTextScanner();
|
||||
// And what the restore preview shows, from the same pass, so the
|
||||
// preview never has to read the copy back (`backup-preview.ts`).
|
||||
const previewScanner = createBackupPreviewScanner();
|
||||
const { chunks, bytes } = await packBackupChunks(
|
||||
async (sealed, seq) => {
|
||||
await tx.dataBackupChunk.create({
|
||||
@@ -129,10 +140,12 @@ export async function storeBackupBlob(
|
||||
(write) =>
|
||||
producer(async (chunk) => {
|
||||
keyScanner.feed(chunk);
|
||||
await previewScanner.feed(chunk);
|
||||
await write(chunk);
|
||||
}),
|
||||
options,
|
||||
);
|
||||
const scanned = await previewScanner.finish();
|
||||
|
||||
// Again at the end: a restore may have been queued while this ran. The
|
||||
// window left is the few milliseconds to the commit, and a restore
|
||||
@@ -147,6 +160,18 @@ export async function storeBackupBlob(
|
||||
chunkStreamId: streamId,
|
||||
innerKeyIds: keyScanner.keyIds(),
|
||||
innerKeyIdsRecorded: true,
|
||||
// Prisma's JSON null clears a preview left by the previous copy.
|
||||
preview: scanned
|
||||
? (buildBackupPreview(
|
||||
storedCopyIdentity({
|
||||
data: null,
|
||||
chunkCount: chunks,
|
||||
chunkStreamId: streamId,
|
||||
})!,
|
||||
scanned.summary,
|
||||
scanned.keys,
|
||||
) as unknown as Prisma.InputJsonValue)
|
||||
: Prisma.DbNull,
|
||||
createdAt: new Date(),
|
||||
},
|
||||
});
|
||||
@@ -156,6 +181,17 @@ export async function storeBackupBlob(
|
||||
);
|
||||
}
|
||||
|
||||
/** A single value's stored preview, when it describes that value. */
|
||||
function previewOfSingleValue(
|
||||
stored: unknown,
|
||||
len: number,
|
||||
head: string,
|
||||
): BackupPreview | null {
|
||||
if (!stored || typeof stored !== "object") return null;
|
||||
const preview = stored as BackupPreview;
|
||||
return preview.copy === singleValueIdentity(len, head) ? preview : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The first characters of a single stored value, which carry its key id, or
|
||||
* null when the row no longer holds one. For the key-rotation scan, which must
|
||||
@@ -208,10 +244,16 @@ export async function convertSingleValueBackup(
|
||||
`SET LOCAL idle_in_transaction_session_timeout = '${STORE_IDLE_TIMEOUT}'`,
|
||||
);
|
||||
const [row] = await tx.$queryRaw<
|
||||
Array<{ head: string; len: number; stream_id: string | null }>
|
||||
Array<{
|
||||
head: string;
|
||||
len: number;
|
||||
stream_id: string | null;
|
||||
preview: unknown;
|
||||
}>
|
||||
>`
|
||||
SELECT left(data, 128) AS head, length(data)::int AS len,
|
||||
chunk_stream_id AS stream_id
|
||||
SELECT left(data, ${SINGLE_VALUE_IDENTITY_HEAD}::int) AS head,
|
||||
length(data)::int AS len,
|
||||
chunk_stream_id AS stream_id, preview
|
||||
FROM data_backups
|
||||
WHERE id = ${id} AND data IS NOT NULL
|
||||
FOR UPDATE
|
||||
@@ -272,9 +314,28 @@ export async function convertSingleValueBackup(
|
||||
// limit on writing a new one.
|
||||
{ maxBytes: Number.MAX_SAFE_INTEGER },
|
||||
);
|
||||
// The content is the same copy in another form, so a preview of it
|
||||
// still holds; it is moved over to the new form's name.
|
||||
const preview = previewOfSingleValue(row.preview, row.len, row.head);
|
||||
await tx.dataBackup.update({
|
||||
where: { id },
|
||||
data: { data: null, chunkCount: chunks, chunkStreamId: streamId },
|
||||
data: {
|
||||
data: null,
|
||||
chunkCount: chunks,
|
||||
chunkStreamId: streamId,
|
||||
...(preview
|
||||
? {
|
||||
preview: {
|
||||
...preview,
|
||||
copy: storedCopyIdentity({
|
||||
data: null,
|
||||
chunkCount: chunks,
|
||||
chunkStreamId: streamId,
|
||||
})!,
|
||||
} as unknown as Prisma.InputJsonValue,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
return "converted";
|
||||
},
|
||||
|
||||
@@ -141,6 +141,71 @@ async function* openedChunks(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse a chunked copy whose row does not list as many pieces as are stored:
|
||||
* one missing, or one this code never wrote. A count, without reading a
|
||||
* piece; `openStoredBackup` goes on to authenticate every piece.
|
||||
*/
|
||||
async function checkStoredBackupPieceCount(
|
||||
prisma: ChunkReader,
|
||||
id: string,
|
||||
streamId: string,
|
||||
count: number,
|
||||
): Promise<void> {
|
||||
if (count < 1) {
|
||||
throw new BackupIntegrityError("The stored copy lists no pieces.");
|
||||
}
|
||||
// Pieces beyond the count would be ignored by the reads, but they are not
|
||||
// something this code ever writes, so their presence is refused too.
|
||||
const stored = await prisma.dataBackupChunk.count({
|
||||
where: { backupId: id },
|
||||
});
|
||||
if (stored !== count) {
|
||||
const now = await prisma.dataBackup.findUnique({
|
||||
where: { id },
|
||||
select: { chunkStreamId: true },
|
||||
});
|
||||
if (now?.chunkStreamId !== streamId) throw new BackupReplacedError();
|
||||
throw new BackupIntegrityError(
|
||||
`The stored copy lists ${count} pieces but ${stored} are stored.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The checks on a stored copy that need no piece read: both forms, a count
|
||||
* without a stream id or the reverse, no content, and the pieces a chunked
|
||||
* copy lists against those stored. Throws what `openStoredBackup` would
|
||||
* throw for the same row. For a reader that answers from what was kept about
|
||||
* the copy (the restore preview) and must still not describe a copy the
|
||||
* restore would refuse on sight.
|
||||
*/
|
||||
export async function checkStoredBackupShape(
|
||||
prisma: ChunkReader,
|
||||
backup: StoredBackupRef,
|
||||
): Promise<void> {
|
||||
if (backup.data != null && backup.chunkStreamId != null) {
|
||||
throw new BackupFormsConflictError();
|
||||
}
|
||||
if ((backup.chunkStreamId == null) !== (backup.chunkCount == null)) {
|
||||
throw new BackupIntegrityError(
|
||||
"The stored copy lists pieces without saying how many, or the reverse.",
|
||||
);
|
||||
}
|
||||
if (backup.chunkStreamId == null || backup.chunkCount == null) {
|
||||
if (backup.data == null) {
|
||||
throw new BackupIntegrityError("The stored copy has no content.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
await checkStoredBackupPieceCount(
|
||||
prisma,
|
||||
backup.id,
|
||||
backup.chunkStreamId,
|
||||
backup.chunkCount,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a stored copy as a source of its JSON bytes, openable as many times as
|
||||
* the caller needs. Rejects when the copy cannot be read whole: a key that is
|
||||
@@ -166,24 +231,7 @@ export async function openStoredBackup(
|
||||
return openBackupBlob(backup.data);
|
||||
}
|
||||
const { id, chunkStreamId: streamId, chunkCount: count } = backup;
|
||||
if (count < 1) {
|
||||
throw new BackupIntegrityError("The stored copy lists no pieces.");
|
||||
}
|
||||
// Pieces beyond the count would be ignored by the reads below, but they are
|
||||
// not something this code ever writes, so their presence is refused too.
|
||||
const stored = await prisma.dataBackupChunk.count({
|
||||
where: { backupId: id },
|
||||
});
|
||||
if (stored !== count) {
|
||||
const now = await prisma.dataBackup.findUnique({
|
||||
where: { id },
|
||||
select: { chunkStreamId: true },
|
||||
});
|
||||
if (now?.chunkStreamId !== streamId) throw new BackupReplacedError();
|
||||
throw new BackupIntegrityError(
|
||||
`The stored copy lists ${count} pieces but ${stored} are stored.`,
|
||||
);
|
||||
}
|
||||
await checkStoredBackupPieceCount(prisma, id, streamId, count);
|
||||
// The whole copy, checked once before anyone reads a byte of it.
|
||||
for await (const piece of openedChunks(prisma, id, streamId, count)) {
|
||||
void piece;
|
||||
|
||||
@@ -339,6 +339,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
tokens: [
|
||||
"tokens.documents.ceiling_reached",
|
||||
"tokens.measurements.ceiling_reached",
|
||||
"tokens.workouts.ceiling_reached",
|
||||
],
|
||||
vaccination: [
|
||||
"vaccination.booster-invalid",
|
||||
@@ -354,6 +355,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
workout: [
|
||||
"workout.batch.invalid",
|
||||
"workout.batch.payload_too_large",
|
||||
"workout.batch.source_not_permitted",
|
||||
"workout.batch.too_large",
|
||||
],
|
||||
"(unprefixed)": [
|
||||
|
||||
@@ -33,6 +33,7 @@ import { oidcNativeTokenSchema } from "@/lib/validations/oidc-native";
|
||||
import {
|
||||
createDocumentTokenSchema,
|
||||
createMeasurementTokenSchema,
|
||||
createWorkoutTokenSchema,
|
||||
} from "@/lib/validations/tokens";
|
||||
import { nativeHandoffTokenSchema } from "@/lib/validations/native-handoff";
|
||||
import {
|
||||
@@ -489,6 +490,28 @@ const createMeasurementTokenResponse = z
|
||||
"The one response that carries a usable measurement-ingest Bearer value.",
|
||||
});
|
||||
|
||||
const createWorkoutTokenRequest = createWorkoutTokenSchema.meta({
|
||||
id: "CreateWorkoutTokenRequest",
|
||||
description:
|
||||
"Mint a workout-ingest Bearer. No `scope` field: the endpoint mints one shape. `name` is what the token is listed under; name it after the bridge it is pasted into. `expiresInDays` defaults to 365.",
|
||||
});
|
||||
|
||||
const createWorkoutTokenResponse = z
|
||||
.object({
|
||||
token: z
|
||||
.string()
|
||||
.describe(
|
||||
"The raw `hlk_` Bearer, RETURNED EXACTLY ONCE. It is stored only as an HMAC-SHA256 hash and cannot be retrieved later. Treat it as a secret.",
|
||||
),
|
||||
name: z.string(),
|
||||
expiresAt: z.iso.datetime({ offset: true }),
|
||||
})
|
||||
.meta({
|
||||
id: "CreateWorkoutTokenResponse",
|
||||
description:
|
||||
"The one response that carries a usable workout-ingest Bearer value.",
|
||||
});
|
||||
|
||||
const createDocumentTokenRequest = createDocumentTokenSchema.meta({
|
||||
id: "CreateDocumentTokenRequest",
|
||||
description:
|
||||
@@ -881,7 +904,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Auth"],
|
||||
summary: "Re-prove a credential on a browser session (cookie only)",
|
||||
description:
|
||||
"Stamps the calling browser session as recently re-proved, so for the next five minutes it may run the actions that ask for a fresh proof: GET /api/export/full-backup, POST /api/export/encrypted, POST /api/share-links, POST /api/mcp/tokens, POST /api/tokens/measurements, POST /api/tokens/documents, and the admin backup download, upload and restore, the admin data wipe and the admin password reset. Those answer 401 `auth.reproof.required` with `meta.methods` (the proofs this account can give) when the session signed in or re-proved more than five minutes ago.\n\n" +
|
||||
"Stamps the calling browser session as recently re-proved, so for the next five minutes it may run the actions that ask for a fresh proof: GET /api/export/full-backup, POST /api/export/encrypted, POST /api/share-links, POST /api/mcp/tokens, POST /api/tokens/measurements, POST /api/tokens/workouts, POST /api/tokens/documents, and the admin backup download, upload and restore, the admin data wipe and the admin password reset. Those answer 401 `auth.reproof.required` with `meta.methods` (the proofs this account can give) when the session signed in or re-proved more than five minutes ago.\n\n" +
|
||||
'The body takes the same shapes as POST /api/auth/step-up. On an account with a second factor only `totp`, `webauthn` (a security key) or `passkey` are accepted, because a password has never stood in for the second factor; on one without, `password` or `passkey`. A method the account cannot use here is refused with 422 `auth.reproof.too_weak` and `meta.methods`, before any guess is spent. A second-factor or passkey proof also refreshes the session\'s second-factor stamp, exactly as signing in again would. Assertions begin at POST /api/auth/passkey/register-options with `{ method: "passkey" | "webauthn" }`.\n\n' +
|
||||
"Proofs draw on the account's shared re-proof budget (five per fifteen minutes, 429 once spent). A proof that does not verify is 401 `auth.reproof.failed` and is audited. Bearer callers use POST /api/auth/step-up instead.",
|
||||
requestBody: {
|
||||
@@ -1687,7 +1710,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
summary: "List the caller's API tokens",
|
||||
description:
|
||||
"Every `ApiToken` row belonging to the caller, newest first, revoked ones included — presence in this list is not validity, `revoked` is. The hash is never returned and there is no path that re-reveals a token's plaintext.\n\n" +
|
||||
"Three things the name understates. The list is not limited to tokens a person minted from the settings surface: a native login mints a wildcard access token as an `ApiToken` row, so a signed-in phone shows up here too. There is no GENERIC mint any more — the POST at this exact path issued `[\"medication:ingest\"]` and was removed, because that scope reached no ingest route while the pre-fail-closed default let it reach everything else. The credentials that work are minted where they are scoped: the per-medication API-endpoint toggle, `POST /api/mcp/tokens` for a connector, the sibling `POST /api/tokens/measurements` for third-party measurement ingest, and `POST /api/tokens/documents` for document upload from another system. And unlike the revoke, this read is NOT gated on the operator's instance-wide API switch: that switch governs the surfaces a token is for, not a token's ability to authenticate, so tokens stay live while it is off and their owner has to be able to see them.",
|
||||
"Three things the name understates. The list is not limited to tokens a person minted from the settings surface: a native login mints a wildcard access token as an `ApiToken` row, so a signed-in phone shows up here too. There is no GENERIC mint any more — the POST at this exact path issued `[\"medication:ingest\"]` and was removed, because that scope reached no ingest route while the pre-fail-closed default let it reach everything else. The credentials that work are minted where they are scoped: the per-medication API-endpoint toggle, `POST /api/mcp/tokens` for a connector, the sibling `POST /api/tokens/measurements` for third-party measurement ingest, `POST /api/tokens/workouts` for workout ingest from a bridge, and `POST /api/tokens/documents` for document upload from another system. And unlike the revoke, this read is NOT gated on the operator's instance-wide API switch: that switch governs the surfaces a token is for, not a token's ability to authenticate, so tokens stay live while it is off and their owner has to be able to see them.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The caller's tokens, newest first.",
|
||||
@@ -1771,6 +1794,70 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/tokens/workouts": {
|
||||
post: {
|
||||
tags: ["Auth"],
|
||||
summary: "Mint a workout-ingest token",
|
||||
description:
|
||||
"Mints a Bearer scoped to exactly `workouts:write` and audits the mint. THE RESPONSE CARRIES THE RAW TOKEN, once; it is stored as an HMAC and no path re-reveals it.\n\n" +
|
||||
"**What it can do.** `POST /api/workouts/batch`, on its owner's own record, and nothing else. It is the door a workout bridge pushes through: a watch vendor's sync relayed by a small service, or a script replaying an exported history. Workouts it writes carry `source: EXTERNAL`, resolved from the credential rather than taken from the body, and a batch in which any entry names a source is refused 422 (`workout.batch.source_not_permitted`) rather than relabelled. `EXTERNAL` is on no default source ladder, so when the phone also recorded the same session the phone's copy is the one the workout list shows.\n\n" +
|
||||
"**What it cannot do.** List or read a workout, reach the measurement or document ingest routes, act on a shared record, or mint another token. It is a separate scope from `measurements:write` on purpose: a token already handed to a scale does not start accepting workouts, and a `measurements:write` token is refused 403 on the workout batch. Every other route names no scope and refuses it 403.\n\n" +
|
||||
"Minting requires a COOKIE SESSION with a fresh proof, as for the measurement mint, for the reason it gives: a day-lived native access token must not be able to leave behind a year-lived credential. Gated by the operator's instance-wide API switch. Body capped at 16 KiB; 10 mints per user per minute, and at most 10 live workout tokens held at once. Tokens appear in `GET /api/tokens` and are revoked at `DELETE /api/tokens/{id}`.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: createWorkoutTokenRequest },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"201": {
|
||||
description:
|
||||
"Token minted. The `token` field is the only copy of the secret.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
createWorkoutTokenResponse,
|
||||
"CreateWorkoutTokenEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"400": {
|
||||
description: "Body is not parseable JSON.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"403": {
|
||||
description:
|
||||
"The operator has switched the API off instance-wide. Nothing was minted. A Bearer caller is refused 401 before the switch is read.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"413": {
|
||||
description: "Body exceeds 16 KiB.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"415": {
|
||||
description: "Content-Type is not `application/json`.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
"422": {
|
||||
description:
|
||||
"Validation failed. The envelope carries every offending issue.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"429": {
|
||||
description:
|
||||
"More than 10 mints from this account in a minute. Nothing was minted.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"409": {
|
||||
description:
|
||||
"The account already holds 10 live workout tokens (`meta.errorCode` = `tokens.workouts.ceiling_reached`). Nothing was minted. Revoke one at `DELETE /api/tokens/{id}`; only live tokens carrying `workouts:write` count.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/tokens/documents": {
|
||||
post: {
|
||||
tags: ["Auth"],
|
||||
|
||||
@@ -144,7 +144,7 @@ export const measurementTypeEnum = measurementTypeEnumBase.meta({
|
||||
export const measurementSourceEnum = measurementSourceEnumBase.meta({
|
||||
id: "MeasurementSource",
|
||||
description:
|
||||
"Origin of the measurement. v1.4.23 added APPLE_HEALTH for the iOS HealthKit batch ingest path. v1.38.x added EXTERNAL for rows pushed in under a narrow `measurements:write` Bearer — a scale, a watch bridge, a home-automation rule. No client may name that value on a write; the server resolves it from the credential.",
|
||||
"Origin of the measurement. v1.4.23 added APPLE_HEALTH for the iOS HealthKit batch ingest path. v1.38.x added EXTERNAL for rows pushed in under a narrow `measurements:write` Bearer — a scale, a watch bridge, a home-automation rule — and workouts pushed in under a narrow `workouts:write` Bearer carry it too. No client may name that value on a write; the server resolves it from the credential.",
|
||||
});
|
||||
|
||||
export const loginPasswordSchema = loginPasswordSchemaBase.meta({
|
||||
|
||||
@@ -319,7 +319,8 @@ export const workoutPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Measurements"],
|
||||
summary: "Typed workout batch ingest (v1.4.25 W16b)",
|
||||
description:
|
||||
'Server-side ingest endpoint for HKWorkout records (iOS) and Withings activity rows (server-to-server). Up to 100 workouts per call; nested route geometry (GeoJSON LineString) is capped at 20 000 points and stored in a 1:1 `WorkoutRoute` row keyed by `workoutId`. Idempotent via the `Idempotency-Key` header (replay window 24h). Per-entry status (`inserted | duplicate | enriched | skipped`) lets the iOS sync cursor checkpoint accurately. An entry matching a stored workout is first-write-wins for the workout row; when it carries a `samples` array and the stored workout has no heart-rate series, the series is attached and the entry reports `enriched` (repeatable: a workout that already has a series is a no-op). An entry whose `samples` array fails validation is refused on its own with `reason: "invalid_samples"` — the rest of the batch still lands. The request body ceiling is 5 MB enforced at the HTTP layer — clients above the ceiling receive a 413 with `workout.batch.payload_too_large`.',
|
||||
'Server-side ingest endpoint for HKWorkout records (iOS) and Withings activity rows (server-to-server). Up to 100 workouts per call; nested route geometry (GeoJSON LineString) is capped at 20 000 points and stored in a 1:1 `WorkoutRoute` row keyed by `workoutId`. Idempotent via the `Idempotency-Key` header (replay window 24h). Per-entry status (`inserted | duplicate | enriched | skipped`) lets the iOS sync cursor checkpoint accurately. An entry matching a stored workout is first-write-wins for the workout row; when it carries a `samples` array and the stored workout has no heart-rate series, the series is attached and the entry reports `enriched` (repeatable: a workout that already has a series is a no-op). An entry whose `samples` array fails validation is refused on its own with `reason: "invalid_samples"` — the rest of the batch still lands. The request body ceiling is 5 MB enforced at the HTTP layer — clients above the ceiling receive a 413 with `workout.batch.payload_too_large`.\n\n' +
|
||||
"**Narrow token.** Also reachable with a `workouts:write` Bearer (minted at `POST /api/tokens/workouts`), the supported way to push workouts from a bridge that is not the phone. This route is the scope's only one. Such a credential writes only on its owner's own record; its workouts are attributed `EXTERNAL`, resolved from the credential, and it may not name a source itself, so a batch in which any entry carries one, `MANUAL` and `APPLE_HEALTH` alike, is refused 422 (`workout.batch.source_not_permitted`) and nothing is written. `EXTERNAL` gives those writes their own `(userId, source, externalId)` namespace, so a bridge's ids can never collide with the phone's. Every other narrow token, `measurements:write` included, is refused 403.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: createBatchWorkoutSchema } },
|
||||
@@ -347,6 +348,13 @@ export const workoutPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
// After the spread: `stdResponses` carries a generic 422 that would
|
||||
// otherwise overwrite this wording.
|
||||
"422": {
|
||||
description:
|
||||
"A narrow `workouts:write` credential sent a batch in which an entry names a source, any source (`workout.batch.source_not_permitted`). Nothing was written; resending the batch unchanged is refused the same way. Omit `source` and the server attributes the rows `EXTERNAL`.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -445,3 +445,34 @@ describe("pickCanonicalWorkout — Strava brick-workout regression", () => {
|
||||
expect(sports).toEqual(["cycling", "running"]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* #1054 — workouts a `workouts:write` bridge pushes in carry `EXTERNAL`, which
|
||||
* is on no default ladder. Pinned because the settings copy promises what
|
||||
* follows from that: a bridged session the phone also recorded shows once, as
|
||||
* the phone's copy, and a bridged session nothing else recorded still shows.
|
||||
*/
|
||||
describe("pickCanonicalWorkout — the unranked EXTERNAL source", () => {
|
||||
it("is on no default ladder", () => {
|
||||
expect(DEFAULT_WORKOUT_SOURCE_PRIORITY).not.toContain("EXTERNAL");
|
||||
});
|
||||
|
||||
it("yields to the phone's copy of the same session", () => {
|
||||
const result = pickCanonicalWorkout([
|
||||
row("bridge-1", "EXTERNAL", "2026-09-20T06:30:00.000Z"),
|
||||
row("apple-1", "APPLE_HEALTH", "2026-09-20T06:31:00.000Z"),
|
||||
]);
|
||||
expect(result.canonical.map((w) => w.id)).toEqual(["apple-1"]);
|
||||
});
|
||||
|
||||
it("survives when nothing else recorded the session", () => {
|
||||
const result = pickCanonicalWorkout([
|
||||
row("bridge-1", "EXTERNAL", "2026-09-20T06:30:00.000Z"),
|
||||
row("apple-1", "APPLE_HEALTH", "2026-09-20T09:00:00.000Z"),
|
||||
]);
|
||||
expect(result.canonical.map((w) => w.id).sort()).toEqual([
|
||||
"apple-1",
|
||||
"bridge-1",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -34,3 +34,13 @@ export const createDocumentTokenSchema = z.object({
|
||||
name: z.string().min(1, "Name required").max(100),
|
||||
expiresInDays: z.number().int().min(1).max(365).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Body of `POST /api/tokens/workouts` — mint a Bearer that can push workouts
|
||||
* and nothing else (#1054). The same shape as the other two mints and for the
|
||||
* same reasons: no scope field, a bounded lifetime.
|
||||
*/
|
||||
export const createWorkoutTokenSchema = z.object({
|
||||
name: z.string().min(1, "Name required").max(100),
|
||||
expiresInDays: z.number().int().min(1).max(365).optional(),
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* The Bearer scope for pushing workouts in from another system (#1054): a
|
||||
* watch vendor's bridge, a script replaying an export.
|
||||
*
|
||||
* A zero-import leaf for the reason `MEASUREMENTS_WRITE_SCOPE` is one (see
|
||||
* `@/lib/measurements/scopes`): the mint route, the batch route, the settings
|
||||
* card and the guards all read it, and none of them should inherit an import
|
||||
* graph for a string.
|
||||
*
|
||||
* Its own scope rather than a second door for `measurements:write`. A token
|
||||
* someone already pasted into a scale's uploader should not quietly start
|
||||
* accepting workouts because a later release widened what the name means; a
|
||||
* scope grants what it names, and a bridge that sends both mints both.
|
||||
*
|
||||
* Deliberately alone. There is no read counterpart and must not be one: the
|
||||
* workout list and detail stay cookie-equivalent, so a credential pasted into
|
||||
* a bridge can add a session and learn nothing about the ones already stored.
|
||||
*/
|
||||
|
||||
/** The scope a narrow token must carry to write workouts. */
|
||||
export const WORKOUTS_WRITE_SCOPE = "workouts:write";
|
||||
Reference in new issue
Block a user