From 4120927a94debfe81b87de58c5d6c9d12558fc35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 1 Oct 2026 22:00:12 +0200 Subject: [PATCH] fix(workouts): count a bridge's batches apart from the phone's A workouts:write token and the iPhone app shared one per-account bucket of 60 batches a minute, so a bridge stuck in a retry loop could use up the allowance the phone needs to sync the same account. Scoped tokens now count in a bucket of their own, shared by all bridge tokens of the account so that minting more tokens does not multiply the allowance. Refs #1054 --- .../workouts/__tests__/batch-scope.test.ts | 36 +++++++++++++++++++ src/app/api/workouts/batch/route.ts | 8 ++++- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/app/api/workouts/__tests__/batch-scope.test.ts b/src/app/api/workouts/__tests__/batch-scope.test.ts index 6fe7001f2..61a063093 100644 --- a/src/app/api/workouts/__tests__/batch-scope.test.ts +++ b/src/app/api/workouts/__tests__/batch-scope.test.ts @@ -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]); diff --git a/src/app/api/workouts/batch/route.ts b/src/app/api/workouts/batch/route.ts index 9d03b0390..8c42af76a 100644 --- a/src/app/api/workouts/batch/route.ts +++ b/src/app/api/workouts/batch/route.ts @@ -232,8 +232,14 @@ async function postBatch(request: NextRequest): Promise { // 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, );