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

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

No files matched your search

@@ -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());
});
});
+37
View File
@@ -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 {
+223
View File
@@ -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;
},
};
}
+66 -5
View File
@@ -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";
},
+66 -18
View File
@@ -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;
+2
View File
@@ -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)": [
+89 -2
View File
@@ -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"],
+1 -1
View File
@@ -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({
+9 -1
View File
@@ -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",
]);
});
});
+10
View File
@@ -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(),
});
+21
View File
@@ -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";