fix(google-health): store the account's id, not the word "profile"

The profile's resource name is `users/{user}/profile`, and the connect
stored its last segment, the same constant for every account. Since the
one-account-per-record check, a second record on an instance could not
connect or reconnect Google Health: it was refused as `already_linked`.

The connect now stores the `{user}` segment, reads `users/me/identity`
when the profile answers through the `me` alias, and stores the id as
unknown when neither names the account. A migration makes the column
nullable and clears the placeholder, so the next connect stores the
real id.
This commit is contained in:
Marc-André Bombeck committed 2026-10-30 13:17:54 +01:00
1 parent 8f8f98eac7
commit cd6cbc06f5
8 files changed
+326 -27

No files matched your search

+2 -5
View File
@@ -9,10 +9,7 @@ import { verifyCallbackActor } from "@/lib/integrations/oauth-kit/connect-target
import { annotate, getEvent } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { encrypt } from "@/lib/crypto";
import {
fetchProfile,
resolveGoogleHealthUserId,
} from "@/lib/google-health/client";
import { fetchProfile, accountIdFor } from "@/lib/google-health/client";
import { googleHealthOAuth } from "@/lib/google-health/oauth";
import { getGoogleHealthScopeString } from "@/lib/google-health/oauth";
import { getUserGoogleHealthCredentials } from "@/lib/google-health/credentials";
@@ -179,7 +176,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
}
const profile = await fetchProfile(tokens.access_token);
const googleUserId = resolveGoogleHealthUserId(profile);
const googleUserId = await accountIdFor(profile, tokens.access_token);
// One Google account, one record.
if (
await accountLinkedElsewhere(
@@ -1,10 +1,19 @@
{
"resolveGoogleHealthUserId": [
{
"profile": { "name": "users/1234567890/profile" },
"userId": "1234567890"
},
{ "profile": { "name": "users/me/profile" }, "userId": null },
{
"profile": { "name": "users/me/profile", "id": "id-0001" },
"userId": "id-0001"
},
{ "profile": { "name": "users/1234567890" }, "userId": "1234567890" },
{ "profile": { "name": "users/", "id": "id-0002" }, "userId": "id-0002" },
{ "profile": { "name": "plain-0003" }, "userId": "plain-0003" },
{ "profile": { "name": "plain-0003" }, "userId": null },
{ "profile": { "id": "id-0004" }, "userId": "id-0004" },
{ "profile": {}, "userId": "me" }
{ "profile": {}, "userId": null }
],
"formatCivilBound": [
{
@@ -337,6 +337,9 @@ describe("Google Health mapper goldens", () => {
];
const output = {
resolveGoogleHealthUserId: [
{ name: "users/1234567890/profile" },
{ name: "users/me/profile" },
{ name: "users/me/profile", id: "id-0001" },
{ name: "users/1234567890" },
{ name: "users/", id: "id-0002" },
{ name: "plain-0003" },
+62 -18
View File
@@ -34,20 +34,15 @@ import {
import { GOOGLE_HEALTH_API_BASE } from "./request";
/**
* The Google Health profile carries the external user id used as the
* connection's `externalUserId`. A single GET, not a paginated collection.
* The Google Health profile. Its resource `name` is `users/{user}/profile`,
* where `{user}` is the Google Health user id, or the literal `me` when the
* profile was read through the `users/me` alias.
* Docs: https://developers.google.com/health/reference/rest/v4/Profile
*/
export interface GoogleHealthProfile {
/**
* External user identifier. Google returns `me`-relative profile data; the
* stable id is surfaced under `name` (a `users/{id}` resource name) or `id`
* depending on the API surface — both are captured here and resolved at the
* call site. Re-verify the exact field against a live account at build.
*/
name?: string;
id?: string;
}
export async function fetchProfile(
accessToken: string,
): Promise<GoogleHealthProfile> {
@@ -89,19 +84,68 @@ export async function fetchProfile(
}
/**
* Resolve the stable external user id from a Google Health profile. The
* `users/{id}` resource name is the canonical anchor; fall back to a bare `id`
* or "me" so the connection always persists a non-empty `externalUserId`.
* The Google Health user id in a profile, or null when it carries none.
*
* The id is the `{user}` segment of `users/{user}/profile`. Until v1.46.4 the
* last path segment was taken, which is the constant `profile` for every
* account: every connection stored the same id, and since the one-account-
* per-record check (v1.44.0) a second profile on the same instance could not
* connect at all. `me` is the alias, not an id, so it resolves to null.
*/
export function resolveGoogleHealthUserId(
profile: GoogleHealthProfile,
): string {
if (profile.name) {
const tail = profile.name.split("/").pop();
if (tail) return tail;
): string | null {
const segment = profile.name?.match(/^users\/([^/]+)/)?.[1];
if (segment && segment !== "me") return segment;
if (profile.id && profile.id !== "me") return profile.id;
return null;
}
/**
* The Google Health user id from `users/me/identity` (`healthUserId`), or
* null when the read fails or carries none. Used when the profile names the
* account only through the `me` alias. Best-effort: an account whose id
* cannot be read still connects, with the id left unknown.
* Docs: https://developers.google.com/health/reference/rest/v4/users/getIdentity
*/
export async function fetchHealthUserId(
accessToken: string,
): Promise<string | null> {
const start = performance.now();
try {
const res = await safeFetch(`${GOOGLE_HEALTH_API_BASE}/users/me/identity`, {
method: "GET",
headers: { Authorization: `Bearer ${accessToken}` },
});
getEvent()?.addExternalCall({
service: "google-health",
method: "fetchIdentity",
duration_ms: Math.round(performance.now() - start),
status: res.status,
});
if (!res.ok) return null;
const json = (await res.json().catch(() => null)) as {
healthUserId?: unknown;
} | null;
const id = json?.healthUserId;
return typeof id === "string" && id !== "" && id !== "me" ? id : null;
} catch {
return null;
}
if (profile.id) return profile.id;
return "me";
}
/**
* The id a connection stores: the profile's, else the identity's, else null.
* An id neither read yields is stored as unknown rather than as a shared
* placeholder, so it never makes two records look like one Google account.
*/
export async function accountIdFor(
profile: GoogleHealthProfile,
accessToken: string,
): Promise<string | null> {
return (
resolveGoogleHealthUserId(profile) ?? (await fetchHealthUserId(accessToken))
);
}
// ─── Daily roll-up reads (`POST …/dataPoints:dailyRollUp`) ─────────────────
+1 -1
View File
@@ -119,7 +119,7 @@ export interface GoogleHealthTokenInfo {
accessToken: string;
/** When `accessToken` stops being accepted. */
expiresAt: Date;
connection: { id: string; googleUserId: string };
connection: { id: string; googleUserId: string | null };
}
/**