fix(polar): lift invalid_grant to reauth and harden the credentials surface

classifyPolarError now lifts a 400 carrying invalid_grant on the token
endpoint from persistent to reauth_required, mirroring Oura via the shared
isOAuthInvalidGrant helper. A stale or replayed authorization code at connect
time now surfaces the reconnect prompt instead of silently parking after 24 h.

The BYO-credentials DELETE now audit-logs and parks the integration ledger at
disconnected for parity with the dedicated disconnect route, and the PUT
audit-logs the credential write. Refresh the connect-route comment that still
described Polar as env-only OAuth; it has resolved BYO-first since v1.17.1.

Add the invalid_grant classification test plus the previously-missing
callback (happy path, CSRF mismatch, cross-user, off-spec token), connect, and
credentials route tests.
This commit is contained in:
Marc-André Bombeck committed 2026-06-15 00:09:22 +02:00
1 parent 574b860eb4
commit 87e570abfe
7 files changed
+348 -5

No files matched your search

@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import {
PolarApiError,
classifyPolarError,
isInvalidGrant,
} from "../response-classifier";
describe("classifyPolarError — invalid_grant reauth lift (H-1)", () => {
it("lifts a 400 invalid_grant on the token endpoint to reauth_required", () => {
const err = new PolarApiError({
verb: "exchangeCode",
classification: "persistent",
httpStatus: 400,
reason: "http_400",
upstreamError: "invalid_grant",
});
expect(isInvalidGrant(err)).toBe(true);
expect(classifyPolarError(err)).toBe("reauth_required");
});
it("recovers the verdict from the message shape after a lost prototype", () => {
// A pg-boss retry can strip the PolarApiError prototype; the message-shape
// fallback must still recognise the invalid_grant signal.
const bare = new Error("Polar exchangeCode error: 400 - invalid_grant");
expect(isInvalidGrant(bare)).toBe(true);
expect(classifyPolarError(bare)).toBe("reauth_required");
});
it("leaves a non-invalid_grant 400 as persistent", () => {
const err = new PolarApiError({
verb: "exchangeCode",
classification: "persistent",
httpStatus: 400,
reason: "http_400",
upstreamError: "invalid_client",
});
expect(isInvalidGrant(err)).toBe(false);
expect(classifyPolarError(err)).toBe("persistent");
});
it("keeps a 401 reauth and a 500 transient classification", () => {
expect(
classifyPolarError(
new PolarApiError({
verb: "fetchSleeps",
classification: "reauth_required",
httpStatus: 401,
reason: "http_401",
}),
),
).toBe("reauth_required");
expect(
classifyPolarError(
new PolarApiError({
verb: "fetchSleeps",
classification: "transient",
httpStatus: 500,
reason: "http_500",
}),
),
).toBe("transient");
});
});
+13
View File
@@ -20,6 +20,7 @@ import {
classifyHttpStatus,
classifyIntegrationError,
IntegrationApiError,
isOAuthInvalidGrant,
type ClassifiedHttpResponse,
type IntegrationClassification,
} from "@/lib/integrations/http-status-classifier";
@@ -48,6 +49,17 @@ export class PolarApiError extends IntegrationApiError {
}
}
/**
* True when a caught error is an OAuth `invalid_grant` on the token endpoint —
* a stale/replayed authorization code on Polar's only token call
* (`exchangeCode`). Lifts a 400 carrying `invalid_grant` from `persistent` to
* `reauth_required` so the connection surfaces the reconnect prompt instead of
* silently parking after 24 h.
*/
export function isInvalidGrant(err: unknown): boolean {
return isOAuthInvalidGrant(err, "Polar");
}
/**
* Read the classification from any caught error. A non-`PolarApiError` defaults
* to `transient` so a plain `Error` retries rather than disabling the
@@ -55,5 +67,6 @@ export class PolarApiError extends IntegrationApiError {
* caller that lost the prototype across a pg-boss retry.
*/
export function classifyPolarError(err: unknown): PolarClassification {
if (isInvalidGrant(err)) return "reauth_required";
return classifyIntegrationError(err, "Polar");
}