feat(integrations): add Polar AccessLink OAuth sync

Connect a Polar account over AccessLink v3: an authorization-code OAuth
flow (shared app from POLAR_CLIENT_ID / POLAR_CLIENT_SECRET) backed by a
stateless HMAC-signed state token doubling as an httpOnly cookie for CSRF.
The callback verifies the signature, expiry, provider binding, and the
byte-exact cookie match, then encrypts the access token plus member id onto
the user row.

An hourly poll-cohort job maps Nightly Recharge recovery, sleep stages,
and daily activity to canonical measurements tagged source POLAR, idempotent
on (userId, type, source, externalId). The recovery band feeds RECOVERY_SCORE
through the canonical ladder, which now ranks native readiness above the
computed proxy. Settings gains a connect / disconnect card. Tokens are never
logged or returned; every egress goes through safeFetch.
This commit is contained in:
Marc-André Bombeck committed 2026-06-14 20:08:50 +02:00
1 parent e561f8d893
commit 31abd307fd
31 files changed
+2341 -13

No files matched your search

+118
View File
@@ -0,0 +1,118 @@
import { prisma } from "@/lib/db";
import { apiHandler } from "@/lib/api-handler";
import { getSession } from "@/lib/auth/session";
import { annotate, getEvent } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { encrypt } from "@/lib/crypto";
import {
exchangeCode,
getPolarCredentials,
registerUser,
} from "@/lib/polar/client";
import {
oauthStateCookieName,
stateMatchesCookie,
verifySignedState,
} from "@/lib/oauth/signed-state";
import { markReconnected } from "@/lib/integrations/status";
import { NextRequest, NextResponse } from "next/server";
/**
* v1.17.0 (F4) — OAuth callback from Polar AccessLink.
*
* Identity + CSRF: the `state` URL param is a stateless HMAC-signed token. The
* callback enforces (1) the param equals the httpOnly cookie byte-for-byte
* (double-submit), and (2) the signature + expiry + `polar` provider binding
* are valid. The `userId` is read from INSIDE the verified token — tamper-
* evident — so identity never comes from the ambient session. When a web
* session cookie is also present we cross-check it matches, so a logged-in user
* can't complete another user's in-flight handshake.
*
* On success: exchange the code (Basic-auth), register the user with the Polar
* app, persist the encrypted access token + member id on `User`, clear any
* prior reauth state, and redirect back to Settings.
*/
const ERR = (reason: string) =>
NextResponse.redirect(
new URL(
`/settings/integrations?polar=error&reason=${reason}`,
process.env.NEXT_PUBLIC_APP_URL!,
),
);
export const GET = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "polar.callback" } });
const { searchParams } = new URL(request.url);
const code = searchParams.get("code");
const state = searchParams.get("state");
const cookieState = request.cookies.get(oauthStateCookieName("polar"))?.value;
// CSRF double-submit: the URL state must equal the cookie (constant-time).
if (!stateMatchesCookie(state, cookieState)) {
annotate({ meta: { reason: "csrf1" } });
return ERR("csrf1");
}
// Verify signature + expiry + provider binding; userId rides inside.
const verified = verifySignedState("polar", state);
if (!verified) {
annotate({ meta: { reason: "state" } });
return ERR("state");
}
const userId = verified.userId;
const evt = getEvent();
if (evt) evt.setAuth({ user_id: userId, auth_method: "session" });
// Optional session cross-check when a web session is present.
const sessionData = await getSession();
if (sessionData && sessionData.user.id !== userId) {
annotate({ meta: { reason: "cross_user" } });
return ERR("cross_user");
}
if (!code) return ERR("nocode");
try {
const creds = getPolarCredentials();
if (!creds) return ERR("nocreds");
const tokens = await exchangeCode(code, creds);
// Guard against an off-spec token body without `x_user_id`: persisting the
// string "undefined" as the member id would break every later data read
// (`/v3/users/undefined/...`) with no clear cause.
if (typeof tokens.x_user_id !== "number" || !Number.isFinite(tokens.x_user_id)) {
return ERR("token");
}
const polarUserId = String(tokens.x_user_id);
// Register the user with the Polar app (idempotent; 409 = already linked).
await registerUser(tokens.access_token, polarUserId);
await prisma.user.update({
where: { id: userId },
data: {
polarAccessTokenEncrypted: encrypt(tokens.access_token),
// Polar issues no refresh token; the column stays null.
polarRefreshTokenEncrypted: null,
polarUserIdEncrypted: encrypt(polarUserId),
},
});
await auditLog("polar.connect", { userId });
await markReconnected(userId, "polar");
const response = NextResponse.redirect(
new URL(
"/settings/integrations?polar=connected",
process.env.NEXT_PUBLIC_APP_URL!,
),
);
response.cookies.delete(oauthStateCookieName("polar"));
return response;
} catch (err) {
getEvent()?.setError(err);
return ERR("token");
}
});
+67
View File
@@ -0,0 +1,67 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
import { checkRateLimit } from "@/lib/rate-limit";
import { getAuthorizationUrl, getPolarCredentials } from "@/lib/polar/client";
import {
OAUTH_STATE_TTL_MS,
mintSignedState,
oauthStateCookieName,
} from "@/lib/oauth/signed-state";
import { NextResponse } from "next/server";
/**
* v1.17.0 (F4) — redirect the user to the Polar AccessLink consent screen.
*
* Full OAuth from env: the shared client id/secret come from `POLAR_CLIENT_ID`
* / `POLAR_CLIENT_SECRET` (not per-user BYO). The CSRF defence is a stateless
* signed state (`src/lib/oauth/signed-state.ts`): the same token is the `state`
* URL param AND an httpOnly cookie, and the callback enforces both signature
* validity and a byte-exact cookie match.
*
* Rate-limited per user so a session can't spam the consent redirect.
*/
const CONNECT_RATE_LIMIT = 10;
const CONNECT_WINDOW_MS = 60_000;
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "polar.connect" } });
const rl = await checkRateLimit(
`polar:connect:${user.id}`,
CONNECT_RATE_LIMIT,
CONNECT_WINDOW_MS,
);
if (!rl.allowed) {
annotate({ action: { name: "polar.connect.rate_limited" } });
return NextResponse.redirect(
new URL(
"/settings/integrations?polar=error&reason=rate_limited",
process.env.NEXT_PUBLIC_APP_URL!,
),
);
}
const creds = getPolarCredentials();
if (!creds) {
return apiError(
"Polar integration is not configured on this server.",
400,
);
}
const state = mintSignedState("polar", user.id);
const url = getAuthorizationUrl(state, creds);
const response = NextResponse.redirect(url);
response.cookies.set(oauthStateCookieName("polar"), state, {
httpOnly: true,
secure: shouldEmitSecureCookie(),
sameSite: "lax",
maxAge: Math.floor(OAUTH_STATE_TTL_MS / 1000),
path: "/",
});
return response;
});
+40
View File
@@ -0,0 +1,40 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { markDisconnected } from "@/lib/integrations/status";
/**
* v1.17.0 (F4) — disconnect Polar for the current user.
*
* Clears the stored OAuth token + member id on `User` and parks the integration
* status at `disconnected`. Imported `source = POLAR` measurements are left
* intact (they are the user's historical readings); a reconnect resumes sync.
*/
export const POST = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "polar.disconnect" } });
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: { polarAccessTokenEncrypted: true },
});
if (!dbUser?.polarAccessTokenEncrypted) {
return apiError("No Polar connection", 404);
}
await prisma.user.update({
where: { id: user.id },
data: {
polarAccessTokenEncrypted: null,
polarRefreshTokenEncrypted: null,
polarUserIdEncrypted: null,
},
});
await auditLog("polar.disconnect", { userId: user.id });
await markDisconnected(user.id, "polar");
return apiSuccess({ disconnected: true });
});
@@ -0,0 +1,83 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({ user: { id: "u1" } })),
}));
vi.mock("@/lib/db", () => ({
prisma: { user: { findUnique: vi.fn() } },
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
vi.mock("@/lib/api-response", () => ({
apiSuccess: (data: unknown) => ({ data, error: null }),
}));
vi.mock("@/lib/polar/client", () => ({
getPolarCredentials: vi.fn(() => ({ clientId: "c", clientSecret: "s" })),
}));
vi.mock("@/lib/integrations/status", () => ({
getIntegrationStatus: vi.fn(async () => ({
integration: "polar",
state: "connected",
lastSuccessAt: "2026-06-10T00:00:00.000Z",
lastAttemptAt: "2026-06-10T00:00:00.000Z",
lastError: null,
consecutiveFailuresByKind: null,
})),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getPolarCredentials } from "@/lib/polar/client";
const userFind = prisma.user.findUnique as ReturnType<typeof vi.fn>;
const credsMock = getPolarCredentials as ReturnType<typeof vi.fn>;
type Body = {
connected: boolean;
configured: boolean;
available: boolean;
state?: string;
};
const call = () =>
(GET as unknown as () => Promise<{ data: Body }>)().then((r) => r.data);
describe("GET /api/polar/status", () => {
beforeEach(() => {
vi.clearAllMocks();
credsMock.mockReturnValue({ clientId: "c", clientSecret: "s" });
});
it("reports not-connected but available when env is set and no token stored", async () => {
userFind.mockResolvedValue({ polarAccessTokenEncrypted: null });
const res = await call();
expect(res.connected).toBe(false);
expect(res.available).toBe(true);
});
it("reports available=false when the server has no Polar app configured", async () => {
credsMock.mockReturnValue(null);
userFind.mockResolvedValue(null);
const res = await call();
expect(res.available).toBe(false);
expect(res.connected).toBe(false);
});
it("reports connected + ledger state when a token is stored", async () => {
userFind.mockResolvedValue({ polarAccessTokenEncrypted: "enc" });
const res = await call();
expect(res.connected).toBe(true);
expect(res.configured).toBe(true);
expect(res.state).toBe("connected");
});
it("never returns the token", async () => {
userFind.mockResolvedValue({ polarAccessTokenEncrypted: "enc" });
const res = (await call()) as Record<string, unknown>;
expect(JSON.stringify(res)).not.toContain("enc");
});
});
+44
View File
@@ -0,0 +1,44 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { getPolarCredentials } from "@/lib/polar/client";
import { getIntegrationStatus } from "@/lib/integrations/status";
/**
* v1.17.0 (F4) — Polar connection status for the current user.
*
* `available` reports whether the server has the shared OAuth app configured
* (env client id/secret) so the card can grey out the connect button when the
* operator hasn't set it up. `connected` is whether a token is stored. The
* ledger snapshot (state / lastSuccessAt / lastError) comes off the shared
* `polar` IntegrationKey. The access token + member id are NEVER returned.
*/
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "polar.status" } });
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: { polarAccessTokenEncrypted: true },
});
const available = !!getPolarCredentials();
const connected = !!dbUser?.polarAccessTokenEncrypted;
if (!connected) {
return apiSuccess({ connected: false, configured: false, available });
}
const status = await getIntegrationStatus(user.id, "polar");
return apiSuccess({
connected: true,
configured: true,
available,
state: status.state,
lastSuccessAt: status.lastSuccessAt,
lastAttemptAt: status.lastAttemptAt,
lastError: status.lastError,
});
});