mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
feat(integrations): per-user Polar and Oura OAuth credentials
Polar and Oura resolved their OAuth client id/secret from the server
environment only, forcing every user on a deploy to share one app. Both
now read per-user BYO keys first, falling back to the shared env app when
unset, mirroring the WHOOP and Fitbit model.
- Add DB-first-then-env resolvers in src/lib/{polar,oura}/credentials.ts,
encrypting the client id/secret at rest on User.
- Repoint the connect, callback, status routes and the Oura refresh path
to the user-scoped resolver.
- Add /api/{polar,oura}/credentials (GET/PUT/DELETE) with Zod-validated
input and userId narrowed from the session.
- Render an optional 'your OAuth app credentials' form on the Polar and
Oura cards; the status read exposes hasOwnCredentials for the saved
placeholder. Existing env-only deploys keep working unchanged.
- Cover both resolvers (DB hit, env fallback, no creds) and the card form
with unit tests; add the new strings across all six locales.
This commit is contained in:
1 parent
111fa7a323
commit
38c7604ce4
28 files changed
+856
-76
No files matched your search
@@ -4,7 +4,8 @@ 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, getOuraCredentials } from "@/lib/oura/client";
|
||||
import { exchangeCode } from "@/lib/oura/client";
|
||||
import { getOuraClientCredentials } from "@/lib/oura/credentials";
|
||||
import {
|
||||
oauthStateCookieName,
|
||||
stateMatchesCookie,
|
||||
@@ -65,7 +66,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
if (!code) return ERR("nocode");
|
||||
|
||||
try {
|
||||
const creds = getOuraCredentials();
|
||||
const creds = await getOuraClientCredentials(userId);
|
||||
if (!creds) return ERR("nocreds");
|
||||
|
||||
const tokens = await exchangeCode(code, creds);
|
||||
|
||||
@@ -3,7 +3,8 @@ 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, getOuraCredentials } from "@/lib/oura/client";
|
||||
import { getAuthorizationUrl } from "@/lib/oura/client";
|
||||
import { getOuraClientCredentials } from "@/lib/oura/credentials";
|
||||
import {
|
||||
OAUTH_STATE_TTL_MS,
|
||||
mintSignedState,
|
||||
@@ -40,7 +41,7 @@ export const GET = apiHandler(async () => {
|
||||
);
|
||||
}
|
||||
|
||||
const creds = getOuraCredentials();
|
||||
const creds = await getOuraClientCredentials(user.id);
|
||||
if (!creds) {
|
||||
return apiError("Oura integration is not configured on this server.", 400);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
storeOuraClientCredentials,
|
||||
clearOuraClientCredentials,
|
||||
} from "@/lib/oura/credentials";
|
||||
import { ouraCredentialsSchema } from "@/lib/validations/oura";
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
/**
|
||||
* v1.17.1 — per-user Oura BYO-key credentials.
|
||||
*
|
||||
* Whether the user has their own Oura client id/secret stored. The
|
||||
* connect/callback/status routes resolve credentials DB-first then env, so an
|
||||
* unset pair simply falls back to the shared env app.
|
||||
*/
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "oura.credentials.get" } });
|
||||
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
ouraClientIdEncrypted: true,
|
||||
ouraClientSecretEncrypted: true,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
hasCredentials:
|
||||
!!dbUser?.ouraClientIdEncrypted && !!dbUser?.ouraClientSecretEncrypted,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Save Oura OAuth client credentials (encrypted at rest).
|
||||
*/
|
||||
export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "oura.credentials.update" } });
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const result = z.safeParse(ouraCredentialsSchema, body);
|
||||
if (!result.success) {
|
||||
return apiError("Client ID and Client Secret are required", 422);
|
||||
}
|
||||
|
||||
await storeOuraClientCredentials(
|
||||
user.id,
|
||||
result.data.clientId,
|
||||
result.data.clientSecret,
|
||||
);
|
||||
|
||||
return apiSuccess({ updated: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* Delete Oura credentials and the active connection.
|
||||
*/
|
||||
export const DELETE = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "oura.credentials.delete" } });
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
ouraAccessTokenEncrypted: null,
|
||||
ouraRefreshTokenEncrypted: null,
|
||||
},
|
||||
});
|
||||
await clearOuraClientCredentials(user.id);
|
||||
|
||||
return apiSuccess({ deleted: true });
|
||||
});
|
||||
@@ -15,8 +15,11 @@ vi.mock("@/lib/api-response", () => ({
|
||||
apiSuccess: (data: unknown) => ({ data, error: null }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/oura/client", () => ({
|
||||
getOuraCredentials: vi.fn(() => ({ clientId: "c", clientSecret: "s" })),
|
||||
vi.mock("@/lib/oura/credentials", () => ({
|
||||
getOuraClientCredentials: vi.fn(async () => ({
|
||||
clientId: "c",
|
||||
clientSecret: "s",
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/integrations/status", () => ({
|
||||
@@ -32,10 +35,10 @@ vi.mock("@/lib/integrations/status", () => ({
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getOuraCredentials } from "@/lib/oura/client";
|
||||
import { getOuraClientCredentials } from "@/lib/oura/credentials";
|
||||
|
||||
const userFind = prisma.user.findUnique as ReturnType<typeof vi.fn>;
|
||||
const credsMock = getOuraCredentials as ReturnType<typeof vi.fn>;
|
||||
const credsMock = getOuraClientCredentials as ReturnType<typeof vi.fn>;
|
||||
|
||||
type Body = {
|
||||
connected: boolean;
|
||||
@@ -49,7 +52,7 @@ const call = () =>
|
||||
describe("GET /api/oura/status", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
credsMock.mockReturnValue({ clientId: "c", clientSecret: "s" });
|
||||
credsMock.mockResolvedValue({ clientId: "c", clientSecret: "s" });
|
||||
});
|
||||
|
||||
it("reports not-connected but available", async () => {
|
||||
|
||||
@@ -2,15 +2,18 @@ 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 { getOuraCredentials } from "@/lib/oura/client";
|
||||
import { getOuraClientCredentials } from "@/lib/oura/credentials";
|
||||
import { getIntegrationStatus } from "@/lib/integrations/status";
|
||||
|
||||
/**
|
||||
* v1.17.0 (F4) — Oura connection status for the current user.
|
||||
*
|
||||
* `available` reports whether the server has the shared OAuth app configured.
|
||||
* `available` reports whether usable OAuth credentials resolve for this user —
|
||||
* per-user BYO keys (v1.17.1) first, then the shared env app.
|
||||
* `hasOwnCredentials` reports whether the user has stored their own BYO pair.
|
||||
* `connected` is whether a token is stored. The ledger snapshot comes off the
|
||||
* shared `oura` IntegrationKey. The access / refresh tokens are NEVER returned.
|
||||
* shared `oura` IntegrationKey. The access / refresh tokens + client secret are
|
||||
* NEVER returned.
|
||||
*/
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
@@ -18,14 +21,25 @@ export const GET = apiHandler(async () => {
|
||||
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { ouraAccessTokenEncrypted: true },
|
||||
select: {
|
||||
ouraAccessTokenEncrypted: true,
|
||||
ouraClientIdEncrypted: true,
|
||||
ouraClientSecretEncrypted: true,
|
||||
},
|
||||
});
|
||||
|
||||
const available = !!getOuraCredentials();
|
||||
const available = !!(await getOuraClientCredentials(user.id));
|
||||
const connected = !!dbUser?.ouraAccessTokenEncrypted;
|
||||
const hasOwnCredentials =
|
||||
!!dbUser?.ouraClientIdEncrypted && !!dbUser?.ouraClientSecretEncrypted;
|
||||
|
||||
if (!connected) {
|
||||
return apiSuccess({ connected: false, configured: false, available });
|
||||
return apiSuccess({
|
||||
connected: false,
|
||||
configured: false,
|
||||
available,
|
||||
hasOwnCredentials,
|
||||
});
|
||||
}
|
||||
|
||||
const status = await getIntegrationStatus(user.id, "oura");
|
||||
@@ -34,6 +48,7 @@ export const GET = apiHandler(async () => {
|
||||
connected: true,
|
||||
configured: true,
|
||||
available,
|
||||
hasOwnCredentials,
|
||||
state: status.state,
|
||||
lastSuccessAt: status.lastSuccessAt,
|
||||
lastAttemptAt: status.lastAttemptAt,
|
||||
|
||||
@@ -4,11 +4,8 @@ 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 { exchangeCode, registerUser } from "@/lib/polar/client";
|
||||
import { getPolarClientCredentials } from "@/lib/polar/credentials";
|
||||
import {
|
||||
oauthStateCookieName,
|
||||
stateMatchesCookie,
|
||||
@@ -75,7 +72,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
if (!code) return ERR("nocode");
|
||||
|
||||
try {
|
||||
const creds = getPolarCredentials();
|
||||
const creds = await getPolarClientCredentials(userId);
|
||||
if (!creds) return ERR("nocreds");
|
||||
|
||||
const tokens = await exchangeCode(code, creds);
|
||||
|
||||
@@ -3,7 +3,8 @@ 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 { getAuthorizationUrl } from "@/lib/polar/client";
|
||||
import { getPolarClientCredentials } from "@/lib/polar/credentials";
|
||||
import {
|
||||
OAUTH_STATE_TTL_MS,
|
||||
mintSignedState,
|
||||
@@ -44,7 +45,7 @@ export const GET = apiHandler(async () => {
|
||||
);
|
||||
}
|
||||
|
||||
const creds = getPolarCredentials();
|
||||
const creds = await getPolarClientCredentials(user.id);
|
||||
if (!creds) {
|
||||
return apiError(
|
||||
"Polar integration is not configured on this server.",
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
storePolarClientCredentials,
|
||||
clearPolarClientCredentials,
|
||||
} from "@/lib/polar/credentials";
|
||||
import { polarCredentialsSchema } from "@/lib/validations/polar";
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
/**
|
||||
* v1.17.1 — per-user Polar BYO-key credentials.
|
||||
*
|
||||
* Whether the user has their own Polar AccessLink client id/secret stored. The
|
||||
* connect/callback/status routes resolve credentials DB-first then env, so an
|
||||
* unset pair simply falls back to the shared env app.
|
||||
*/
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "polar.credentials.get" } });
|
||||
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
polarClientIdEncrypted: true,
|
||||
polarClientSecretEncrypted: true,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
hasCredentials:
|
||||
!!dbUser?.polarClientIdEncrypted && !!dbUser?.polarClientSecretEncrypted,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Save Polar OAuth client credentials (encrypted at rest).
|
||||
*/
|
||||
export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "polar.credentials.update" } });
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const result = z.safeParse(polarCredentialsSchema, body);
|
||||
if (!result.success) {
|
||||
return apiError("Client ID and Client Secret are required", 422);
|
||||
}
|
||||
|
||||
await storePolarClientCredentials(
|
||||
user.id,
|
||||
result.data.clientId,
|
||||
result.data.clientSecret,
|
||||
);
|
||||
|
||||
return apiSuccess({ updated: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* Delete Polar credentials and the active connection.
|
||||
*/
|
||||
export const DELETE = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "polar.credentials.delete" } });
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
polarAccessTokenEncrypted: null,
|
||||
polarUserIdEncrypted: null,
|
||||
},
|
||||
});
|
||||
await clearPolarClientCredentials(user.id);
|
||||
|
||||
return apiSuccess({ deleted: true });
|
||||
});
|
||||
@@ -15,8 +15,11 @@ 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/polar/credentials", () => ({
|
||||
getPolarClientCredentials: vi.fn(async () => ({
|
||||
clientId: "c",
|
||||
clientSecret: "s",
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/integrations/status", () => ({
|
||||
@@ -32,10 +35,10 @@ vi.mock("@/lib/integrations/status", () => ({
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getPolarCredentials } from "@/lib/polar/client";
|
||||
import { getPolarClientCredentials } from "@/lib/polar/credentials";
|
||||
|
||||
const userFind = prisma.user.findUnique as ReturnType<typeof vi.fn>;
|
||||
const credsMock = getPolarCredentials as ReturnType<typeof vi.fn>;
|
||||
const credsMock = getPolarClientCredentials as ReturnType<typeof vi.fn>;
|
||||
|
||||
type Body = {
|
||||
connected: boolean;
|
||||
@@ -49,7 +52,7 @@ const call = () =>
|
||||
describe("GET /api/polar/status", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
credsMock.mockReturnValue({ clientId: "c", clientSecret: "s" });
|
||||
credsMock.mockResolvedValue({ clientId: "c", clientSecret: "s" });
|
||||
});
|
||||
|
||||
it("reports not-connected but available when env is set and no token stored", async () => {
|
||||
@@ -60,7 +63,7 @@ describe("GET /api/polar/status", () => {
|
||||
});
|
||||
|
||||
it("reports available=false when the server has no Polar app configured", async () => {
|
||||
credsMock.mockReturnValue(null);
|
||||
credsMock.mockResolvedValue(null);
|
||||
userFind.mockResolvedValue(null);
|
||||
const res = await call();
|
||||
expect(res.available).toBe(false);
|
||||
|
||||
@@ -2,17 +2,19 @@ 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 { getPolarClientCredentials } from "@/lib/polar/credentials";
|
||||
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.
|
||||
* `available` reports whether usable OAuth credentials resolve for this user —
|
||||
* per-user BYO keys (v1.17.1) first, then the shared env app — so the card can
|
||||
* grey out the connect button when neither is set. `hasOwnCredentials` reports
|
||||
* whether the user has stored their own BYO pair (drives the saved-placeholder
|
||||
* UI). `connected` is whether a token is stored. The ledger snapshot (state /
|
||||
* lastSuccessAt / lastError) comes off the shared `polar` IntegrationKey. The
|
||||
* access token, member id, and client secret are NEVER returned.
|
||||
*/
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
@@ -20,14 +22,25 @@ export const GET = apiHandler(async () => {
|
||||
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { polarAccessTokenEncrypted: true },
|
||||
select: {
|
||||
polarAccessTokenEncrypted: true,
|
||||
polarClientIdEncrypted: true,
|
||||
polarClientSecretEncrypted: true,
|
||||
},
|
||||
});
|
||||
|
||||
const available = !!getPolarCredentials();
|
||||
const available = !!(await getPolarClientCredentials(user.id));
|
||||
const connected = !!dbUser?.polarAccessTokenEncrypted;
|
||||
const hasOwnCredentials =
|
||||
!!dbUser?.polarClientIdEncrypted && !!dbUser?.polarClientSecretEncrypted;
|
||||
|
||||
if (!connected) {
|
||||
return apiSuccess({ connected: false, configured: false, available });
|
||||
return apiSuccess({
|
||||
connected: false,
|
||||
configured: false,
|
||||
available,
|
||||
hasOwnCredentials,
|
||||
});
|
||||
}
|
||||
|
||||
const status = await getIntegrationStatus(user.id, "polar");
|
||||
@@ -36,6 +49,7 @@ export const GET = apiHandler(async () => {
|
||||
connected: true,
|
||||
configured: true,
|
||||
available,
|
||||
hasOwnCredentials,
|
||||
state: status.state,
|
||||
lastSuccessAt: status.lastSuccessAt,
|
||||
lastAttemptAt: status.lastAttemptAt,
|
||||
|
||||
Reference in new issue
Block a user