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
This commit is contained in:
Marc-André Bombeck committed 2026-10-01 22:00:12 +02:00
1 parent 08d5acdcb5
commit 4120927a94
2 files changed
+43 -1

No files matched your search

@@ -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,
);