Merge remote-tracking branch 'origin/main' into fix/v1398-polish

This commit is contained in:
Marc-André Bombeck committed 2026-10-01 22:23:09 +02:00
commit ba426706be
7 files changed
+131 -5

No files matched your search

+9 -4
View File
@@ -64,10 +64,15 @@ const MAX_SAMPLES = 32_768;
// any real recording. A DoS ceiling, not a tight bound.
const MAX_BODY_BYTES = 2 * 1024 * 1024;
// One recording per request, so the limit is per recording. A person records
// a handful of strips a week; 60/min still drains a full watch history in
// minutes and stops a leaked wildcard token from saturating the write path.
const INGEST_RATE_LIMIT_MAX = 60;
// One recording per request, so the limit is per recording. The iPhone app
// sends every recording its sweep has not yet consumed in one go and, on a
// 429, stops and starts the same sweep again on its next wake. A window that
// held more than the limit could therefore never finish: with 60 per minute
// the 61st recording was refused on every sync and the newest ECG never
// arrived (#1060). 600 covers more than a decade of weekly strips in one
// sweep and still stops a leaked token from saturating the write path; a
// request is at most 2 MB and usually about 100 KB.
const INGEST_RATE_LIMIT_MAX = 600;
const INGEST_RATE_LIMIT_WINDOW_MS = 60 * 1000;
// Only the three ECG verdicts. `RhythmClassification` in the database has six
@@ -207,6 +207,42 @@ describe("POST /api/workouts/batch — which credentials it admits", () => {
});
});
describe("POST /api/workouts/batch — rate limit buckets", () => {
function lastBucket(): string {
const calls = vi.mocked(checkRateLimit).mock.calls;
return calls[calls.length - 1][0] as string;
}
it("counts a workouts:write token apart from the phone", async () => {
armToken([WORKOUTS_WRITE_SCOPE]);
await POST(makeRequest({ workouts: [workout("s-1")] }));
const bridge = lastBucket();
armToken(["*"]);
await POST(
makeRequest({ workouts: [workout("s-2", { source: "APPLE_HEALTH" })] }),
);
const phone = lastBucket();
expect(bridge).not.toBe(phone);
expect(bridge).toContain(USER.id);
expect(phone).toContain(USER.id);
});
it("keeps a session and a wildcard token in the same bucket", async () => {
armSession();
await POST(
makeRequest({ workouts: [workout("s-1", { source: "APPLE_HEALTH" })] }),
);
const session = lastBucket();
armToken(["*"]);
await POST(
makeRequest({ workouts: [workout("s-2", { source: "APPLE_HEALTH" })] }),
);
expect(lastBucket()).toBe(session);
});
});
describe("POST /api/workouts/batch — what a scoped caller writes", () => {
it("attributes its rows to EXTERNAL", async () => {
armToken([WORKOUTS_WRITE_SCOPE]);
+7 -1
View File
@@ -232,8 +232,14 @@ async function postBatch(request: NextRequest): Promise<Response> {
// database for every other user on the host. 60 batches/min/user is
// generous for healthy iOS sync and tight enough that a misbehaving
// client bottoms out within a minute.
//
// A scoped `workouts:write` token is a bridge, not the phone, so it counts
// in a bucket of its own: a bridge stuck in a retry loop must not use up
// the allowance the iPhone app needs to sync the same account's workouts.
// All bridge tokens of one account share that bucket, so minting more
// tokens does not multiply the allowance.
const rl = await checkRateLimit(
`workouts:batch:${user.id}`,
scoped ? `workouts:batch:bridge:${user.id}` : `workouts:batch:${user.id}`,
BATCH_RATE_LIMIT_MAX,
BATCH_RATE_LIMIT_WINDOW_MS,
);