diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 17fd03bc0..133ffc152 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -22032,10 +22032,11 @@ paths:
tags:
- Account sharing
summary: Read the managed record's integration connection states
- description: "Disclosure only: whether each of the record's integrations is connected and when it last succeeded. There
- is deliberately no control arm and no credential projection anywhere under this path — a Guardian cannot
- connect, disconnect, probe or resume a managed record's provider, because an OAuth flow cannot safely carry a
- record selector. Cookie session or Bearer, while acting on a managed record the caller guards."
+ description: "Disclosure only: whether each of the record's integrations is connected, when it last succeeded, and its
+ server-resolved health. There is deliberately no control arm and no credential projection anywhere under this
+ path — a Guardian cannot connect, disconnect, probe or resume a managed record's provider, because an OAuth flow
+ cannot safely carry a record selector. Cookie session or Bearer, while acting on a managed record the caller
+ guards."
responses:
"200":
description: Connection state per provider.
@@ -79496,11 +79497,18 @@ components:
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- type: "null"
+ health:
+ anyOf:
+ - $ref: "#/components/schemas/ConnectionHealth"
+ - type: "null"
+ description: The same server-resolved connection state the owner's own envelope carries, null while the provider is not
+ connected. The web renders this; `state` stays for one transition release.
required:
- integration
- state
- lastSuccessAt
- lastAttemptAt
+ - health
additionalProperties: false
description: All eight providers, always, in the fixed order withings, whoop, fitbit, nightscout, polar, oura,
google-health, strava. Note the order differs from the owner's own `/api/integrations/status` envelope.
@@ -79508,11 +79516,11 @@ components:
- recordId
- integrations
additionalProperties: false
- description: Whether the managed record's integrations are connected, and nothing else. Deliberately no control and no
- credential projection — OAuth and provider state cannot safely carry a record selector, so a Guardian can see
- that a pipe exists but cannot connect, disconnect, probe or resume it from here. A provider with no real
- credential reads `disconnected` even when its ledger row says otherwise, because a synthesised healthy ledger is
- not proof of a connection.
+ description: Whether the managed record's integrations are connected and how each is doing (`health`), and nothing else.
+ Deliberately no control and no credential projection — OAuth and provider state cannot safely carry a record
+ selector, so a Guardian can see that a pipe exists but cannot connect, disconnect, probe or resume it from here.
+ A provider with no real credential reads `disconnected` even when its ledger row says otherwise, because a
+ synthesised healthy ledger is not proof of a connection.
GetRecordSettingsFamilyEnvelope:
type: object
properties:
diff --git a/src/app/api/record-settings/integrations/route.ts b/src/app/api/record-settings/integrations/route.ts
index 295e24733..f649858eb 100644
--- a/src/app/api/record-settings/integrations/route.ts
+++ b/src/app/api/record-settings/integrations/route.ts
@@ -9,6 +9,7 @@ import {
getIntegrationStatus,
type IntegrationKey,
} from "@/lib/integrations/status";
+import { loadConnectionHealth } from "@/lib/integrations/connection-health-load";
import { annotate } from "@/lib/logging/context";
import {
resolveGuardianRecordSettingsAccess,
@@ -70,6 +71,13 @@ export const GET = apiHandler(async () => {
oura: context.user.ouraAccessTokenEncrypted !== null,
strava: context.user.stravaAccessTokenEncrypted !== null,
};
+ // The one server-resolved state per connection, from the same loader the
+ // owner's own envelope uses, so a guardian reads the record's connections in
+ // the words the record's owner would.
+ const health = await loadConnectionHealth(
+ access.recordId,
+ MANAGED_INTEGRATIONS,
+ );
const integrations = statuses.map((status) => ({
integration: status.integration,
state: resolveManagedIntegrationState(
@@ -79,6 +87,7 @@ export const GET = apiHandler(async () => {
),
lastSuccessAt: status.lastSuccessAt,
lastAttemptAt: status.lastAttemptAt,
+ health: health[status.integration],
}));
annotate({ action: { name: "record-settings.integrations.get" } });
diff --git a/src/components/settings/__tests__/managed-integration-status.test.tsx b/src/components/settings/__tests__/managed-integration-status.test.tsx
new file mode 100644
index 000000000..8cc091ea6
--- /dev/null
+++ b/src/components/settings/__tests__/managed-integration-status.test.tsx
@@ -0,0 +1,133 @@
+/**
+ * A managed record's connections on the shared integration card, read-only.
+ */
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import type { ConnectionHealth } from "@/lib/integrations/connection-health";
+
+vi.mock("@/hooks/use-auth", () => ({
+ useAuth: () => ({
+ user: {
+ id: "guardian",
+ accountAccess: {
+ active: {
+ accountId: "record-1",
+ recordKind: "managed",
+ displayName: "Managed record",
+ },
+ },
+ },
+ isAuthenticated: true,
+ isLoading: false,
+ }),
+}));
+
+let payload: unknown;
+let loading = false;
+vi.mock("@tanstack/react-query", () => ({
+ useQuery: () => ({
+ data: payload,
+ isLoading: loading,
+ isError: false,
+ refetch: vi.fn(),
+ }),
+}));
+
+import { I18nProvider } from "@/lib/i18n/context";
+import { ManagedIntegrationStatus } from "../managed-integration-status";
+import { healthFixture } from "../integrations/__tests__/health-fixture";
+
+const KEYS = [
+ "withings",
+ "whoop",
+ "fitbit",
+ "nightscout",
+ "polar",
+ "oura",
+ "google-health",
+ "strava",
+];
+
+function render() {
+ return renderToStaticMarkup(
+
+
+ ,
+ );
+}
+
+function withHealth(map: Record) {
+ return {
+ recordId: "record-1",
+ integrations: KEYS.map((integration) => ({
+ integration,
+ // The legacy fields still ride the wire; the view must not use them.
+ state: "connected",
+ lastSuccessAt: null,
+ lastAttemptAt: null,
+ health: map[integration] ?? null,
+ })),
+ };
+}
+
+describe("", () => {
+ beforeEach(() => {
+ payload = undefined;
+ loading = false;
+ });
+
+ it("renders one shared card per provider from health, with no control on any", () => {
+ payload = withHealth({
+ oura: healthFixture({
+ state: "action_required",
+ reason: "reauth_required",
+ action: "reconnect",
+ }),
+ withings: healthFixture(),
+ });
+ const html = render();
+
+ expect(html).toContain('data-record-settings-family="integrations"');
+ expect(html).toContain(
+ 'aria-labelledby="managed-integration-status-title"',
+ );
+ expect(html).toContain('data-record-id="record-1"');
+ expect(html.match(/data-slot="integration-card"/g)).toHaveLength(8);
+ for (const key of KEYS) {
+ expect(html).toContain(`data-testid="managed-integration-${key}"`);
+ }
+ expect(html).toMatch(
+ /data-testid="managed-integration-oura"[\s\S]*?data-slot="integration-health-badge"[^>]*data-state="action_required"/,
+ );
+ // Read-only even where the state asks for an action.
+ expect(html).not.toContain('data-slot="integration-primary-action"');
+ expect(html).not.toContain('data-slot="integration-menu-trigger"');
+ // The legacy `state: "connected"` on every row does not make a card
+ // connected; only `health` does.
+ expect(html.match(/>Not connected\. {
+ payload = withHealth({});
+ const html = render();
+ expect(html).toContain('data-slot="managed-integration-read-only"');
+ expect(html).toContain(
+ "A managed profile shows its connected services as a status only.",
+ );
+ expect(html).toContain(
+ 'href="https://docs.healthlog.dev/self-hosting/account-sharing#records-with-no-login"',
+ );
+ });
+
+ it("paints the cards while loading without claiming a state", () => {
+ loading = true;
+ const html = render();
+ expect(html).toContain('aria-busy="true"');
+ expect(html).not.toContain(
+ 'aria-labelledby="managed-integration-status-title"',
+ );
+ expect(html).not.toContain('data-slot="integration-health-badge"');
+ expect(html).not.toContain("Not connected.");
+ });
+});
diff --git a/src/components/settings/integrations/__tests__/cards-render-health.test.ts b/src/components/settings/integrations/__tests__/cards-render-health.test.ts
new file mode 100644
index 000000000..5ae749d7e
--- /dev/null
+++ b/src/components/settings/integrations/__tests__/cards-render-health.test.ts
@@ -0,0 +1,160 @@
+/**
+ * The integration cards render the server's `health` and nothing else.
+ *
+ * Before v1.42.4 each card combined the ledger state, the liveness verdict,
+ * token expiry, plan and scope lists and a reauth flag in its own way, so one
+ * fact could read as a red error on one card and a footnote on the next. The
+ * server now folds all of it into `health`, and this pins that no card under
+ * this folder starts reading the old fields again, which is the one way a
+ * second status dialect could come back. The managed-profile status view is
+ * held to the same rule.
+ */
+import { describe, expect, it } from "vitest";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+
+import ts from "typescript";
+
+import { walkSourceFiles } from "@/__tests__/helpers/source-files";
+
+import { failureStreakFor, pickStatus } from "../shared";
+
+describe("failureStreakFor", () => {
+ it("pairs the largest per-kind streak with the envelope threshold", () => {
+ const status = pickStatus(
+ {
+ threshold: 3,
+ integrations: [
+ {
+ integration: "withings",
+ consecutiveFailuresByKind: {
+ transient: 1,
+ reauth_required: 2,
+ persistent: 0,
+ },
+ },
+ ],
+ },
+ "withings",
+ );
+ expect(failureStreakFor(status)).toEqual({ count: 2, threshold: 3 });
+ });
+
+ it("reports nothing for a healthy zero-count status", () => {
+ const status = pickStatus(
+ {
+ threshold: 3,
+ integrations: [
+ {
+ integration: "withings",
+ consecutiveFailuresByKind: {
+ transient: 0,
+ reauth_required: 0,
+ persistent: 0,
+ },
+ },
+ ],
+ },
+ "withings",
+ );
+ expect(failureStreakFor(status)).toBeNull();
+ });
+});
+
+describe("structural guard: cards read health, not the retired fields", () => {
+ const SRC = join(process.cwd(), "src");
+ const CARDS = "components/settings/integrations";
+ const MANAGED = "components/settings/managed-integration-status.tsx";
+
+ /**
+ * Fields the server keeps on the wire for one transition release and that
+ * `health` replaces. Matched as property reads in the parsed source, so a
+ * comment or an i18n key that happens to contain the word is not a read.
+ */
+ const RETIRED = new Set([
+ "lastError",
+ "state",
+ "needsReauth",
+ "planUnavailable",
+ "scopeReconnect",
+ "syncHealth",
+ "tokenExpired",
+ "tokenExpiresAt",
+ "hasActivityScope",
+ "legacyLastSyncedAt",
+ "consecutiveFailures",
+ ]);
+
+ /**
+ * `.state` is also the name of `health.state` and of a Google Health sync
+ * run's progress state. Those bases are the allowed readers; any other base
+ * reading `.state` is a ledger state finding its way back onto a card.
+ */
+ const STATE_READERS = /(^|\.)(health|progress|syncProgress|record)$/;
+
+ const files = [
+ ...walkSourceFiles(join(SRC, CARDS), { floor: 15 })
+ .filter((p) => !p.includes("__tests__") && !p.endsWith(".test.ts"))
+ .map((p) => `${CARDS}/${p}`),
+ MANAGED,
+ ];
+
+ function retiredReads(rel: string): string[] {
+ const path = join(SRC, rel);
+ const file = ts.createSourceFile(
+ path,
+ readFileSync(path, "utf8"),
+ ts.ScriptTarget.Latest,
+ true,
+ path.endsWith("x") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
+ );
+ const found: string[] = [];
+ function visit(node: ts.Node): void {
+ if (ts.isPropertyAccessExpression(node)) {
+ const name = node.name.text;
+ const base = node.expression.getText(file).replace(/\?$/, "");
+ if (
+ RETIRED.has(name) &&
+ !(name === "state" && STATE_READERS.test(base))
+ ) {
+ found.push(`${rel}: ${base}.${name}`);
+ }
+ }
+ ts.forEachChild(node, visit);
+ }
+ visit(file);
+ return found;
+ }
+
+ it("walks the cards and the managed view", () => {
+ expect(files.length).toBeGreaterThan(10);
+ expect(files).toContain(`${CARDS}/integration-card.tsx`);
+ expect(files).toContain(`${CARDS}/oauth-integration-card.tsx`);
+ expect(files).toContain(MANAGED);
+ });
+
+ it("no card reads a retired health-shaped field", () => {
+ expect(files.flatMap(retiredReads)).toEqual([]);
+ });
+
+ it("the card data types do not declare them either", () => {
+ // The compiler is the stronger half of this guard: a field the view model
+ // does not declare cannot be read without a cast.
+ const shared = readFileSync(join(SRC, CARDS, "shared.tsx"), "utf8");
+ const viewModel = shared.slice(
+ shared.indexOf("export interface IntegrationStatusViewModel"),
+ shared.indexOf("export interface IntegrationStatusEnvelope"),
+ );
+ expect(viewModel.length).toBeGreaterThan(100);
+ for (const field of RETIRED) {
+ expect(viewModel, field).not.toMatch(new RegExp(`\\b${field}\\??:`));
+ }
+ });
+
+ it("no card paints the old status pill", () => {
+ const offenders = files.filter((rel) =>
+ / = {},
+): ConnectionHealth {
+ return {
+ state: "ok",
+ reason: "data_arriving",
+ since: null,
+ action: null,
+ unavailable: [],
+ lastSuccessAt: "2026-10-15T08:12:00.000Z",
+ lastDataAt: "2026-10-15T07:12:00.000Z",
+ diagnostics: {
+ ledgerState: "connected",
+ failingLegs: [],
+ failingSince: null,
+ lastAttemptAt: "2026-10-15T08:12:00.000Z",
+ verdict: "fresh",
+ recentCalls: [],
+ },
+ ...overrides,
+ };
+}
diff --git a/src/components/settings/integrations/__tests__/integration-card.test.tsx b/src/components/settings/integrations/__tests__/integration-card.test.tsx
new file mode 100644
index 000000000..feedd6ee3
--- /dev/null
+++ b/src/components/settings/integrations/__tests__/integration-card.test.tsx
@@ -0,0 +1,403 @@
+/**
+ * The shared integration card, one test per state the server can publish.
+ *
+ * Every assertion reads the markup's data attributes or the bundle's own
+ * sentence, never a computed state: the card renders `health` and nothing
+ * else, so what is checked here is that each `health` lands on the right
+ * badge, sentence and (at most one) button.
+ */
+import { describe, expect, it, vi } from "vitest";
+import { createElement } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+
+vi.mock("@tanstack/react-query", () => ({
+ useMutation: () => ({ mutate: vi.fn(), isPending: false }),
+ useQueryClient: () => ({ invalidateQueries: vi.fn() }),
+}));
+
+let lastCardProps: IntegrationCardProps | null = null;
+vi.mock("../integration-card", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ IntegrationCard: (props: IntegrationCardProps) => {
+ lastCardProps = props;
+ return createElement(actual.IntegrationCard, props);
+ },
+ };
+});
+
+import type { Locale } from "@/lib/i18n/config";
+import { I18nProvider } from "@/lib/i18n/context";
+import type { ConnectionHealth } from "@/lib/integrations/connection-health";
+
+import type { IntegrationCardProps } from "../integration-card";
+import {
+ OAUTH_INTEGRATIONS,
+ OAuthIntegrationCard,
+ type OAuthIntegrationKey,
+} from "../oauth-integration-card";
+import type { IntegrationStatusViewModel } from "../shared";
+import { healthFixture } from "./health-fixture";
+
+const NOW = Date.now();
+const hoursAgo = (h: number) => new Date(NOW - h * 3_600_000).toISOString();
+
+function render(
+ provider: OAuthIntegrationKey,
+ viewModel: Partial | undefined,
+ locale: Locale = "en",
+) {
+ return renderToStaticMarkup(
+
+
+ ,
+ );
+}
+
+function connected(health: ConnectionHealth, extra = {}) {
+ return {
+ connected: true,
+ configured: true,
+ available: true,
+ health,
+ ...extra,
+ };
+}
+
+function badgeState(html: string): string | null {
+ return (
+ html.match(
+ /data-slot="integration-health-badge"[^>]*data-state="(\w+)"/,
+ )?.[1] ?? null
+ );
+}
+
+function primaryActions(html: string): string[] {
+ return [
+ ...html.matchAll(
+ /data-slot="integration-primary-action"[^>]*data-action="(\w+)"/g,
+ ),
+ ].map((m) => m[1]);
+}
+
+describe(" per state", () => {
+ it("ok: Working, one sentence, last data, no button", () => {
+ const html = render(
+ "oura",
+ connected(healthFixture({ lastDataAt: hoursAgo(1) })),
+ );
+ expect(html).toMatch(/data-slot="integration-card"[^>]*data-state="ok"/);
+ expect(badgeState(html)).toBe("ok");
+ expect(html).toContain(">Working<");
+ expect(html).toContain("Data is arriving.");
+ expect(html).toContain('data-slot="integration-last-data"');
+ expect(html).toContain("Last data: today,");
+ expect(primaryActions(html)).toEqual([]);
+ // Details are always there, collapsed.
+ expect(html).toContain('data-slot="integration-details-toggle"');
+ expect(html).toMatch(
+ /data-slot="integration-details"[^>]*data-state="closed"/,
+ );
+ expect(html).toMatch(
+ /
]*hidden=""[^>]*data-slot="integration-details"/,
+ );
+ });
+
+ it("ok while older history loads: the sentence says so", () => {
+ const html = render(
+ "whoop",
+ connected(healthFixture(), { backfillCompleted: false }),
+ );
+ expect(html).toContain(
+ "Data is arriving. Older history is loading in the background.",
+ );
+ });
+
+ it("limited by plan: the sentence names up to three, counts the rest, no button", () => {
+ const html = render(
+ "withings",
+ connected(
+ healthFixture({
+ state: "limited",
+ reason: "plan_limited",
+ unavailable: [
+ { item: "spo2", cause: "plan" },
+ { item: "vascularAge", cause: "plan" },
+ { item: "pulseWaveVelocity", cause: "plan" },
+ { item: "vo2Max", cause: "plan" },
+ { item: "ecg", cause: "plan" },
+ ],
+ }),
+ ),
+ );
+ expect(badgeState(html)).toBe("limited");
+ expect(html).toContain(">Limited<");
+ expect(html).toContain(
+ "Data is arriving. Your Withings plan does not include SpO2, vascular age, and 3 more.",
+ );
+ expect(primaryActions(html)).toEqual([]);
+ // The full list lives in the details, with the plans page one tap away.
+ const details = html.slice(html.indexOf('data-slot="integration-details"'));
+ expect(details).toContain("ECG and AFib");
+ expect(details).toContain('data-testid="withings-plan-link"');
+ });
+
+ it("limited by permission: the sentence names it and offers the one reconnect", () => {
+ const html = render(
+ "oura",
+ connected(
+ healthFixture({
+ state: "limited",
+ reason: "permission_limited",
+ action: "reconnect",
+ unavailable: [
+ { item: "spo2", cause: "permission" },
+ { item: "vo2max", cause: "permission" },
+ ],
+ }),
+ ),
+ );
+ expect(badgeState(html)).toBe("limited");
+ expect(html).toContain(
+ "Data is arriving. Permission is missing for SpO2 and VO2 max.",
+ );
+ expect(primaryActions(html)).toEqual(["reconnect"]);
+ expect(html).toContain(">Reconnect<");
+ });
+
+ it("degraded: Delayed, dated, reassuring, no button", () => {
+ const html = render(
+ "withings",
+ connected(
+ healthFixture({
+ state: "degraded",
+ reason: "provider_errors",
+ since: hoursAgo(2),
+ }),
+ ),
+ );
+ expect(badgeState(html)).toBe("degraded");
+ expect(html).toContain(">Delayed<");
+ expect(html).toMatch(
+ /Withings has not answered reliably since (today|yesterday), [^.]+\. HealthLog keeps trying; nothing to do on your side\./,
+ );
+ expect(primaryActions(html)).toEqual([]);
+ });
+
+ it("action required, reauth: Action needed and exactly one Reconnect", () => {
+ const html = render(
+ "google-health",
+ connected(
+ healthFixture({
+ state: "action_required",
+ reason: "reauth_required",
+ action: "reconnect",
+ }),
+ ),
+ );
+ expect(badgeState(html)).toBe("action_required");
+ expect(html).toContain(">Action needed<");
+ expect(html).toContain(
+ "The Google Health connection has expired. Reconnect to keep data coming.",
+ );
+ expect(primaryActions(html)).toEqual(["reconnect"]);
+ // The re-consent banner it replaces is gone.
+ expect(html).not.toContain("google-health-reauth-banner");
+ });
+
+ it("action required, paused: exactly one Resume, wired to the resume endpoint", () => {
+ const html = render(
+ "whoop",
+ connected(
+ healthFixture({
+ state: "action_required",
+ reason: "paused_after_errors",
+ since: hoursAgo(30),
+ action: "resume",
+ }),
+ ),
+ );
+ expect(badgeState(html)).toBe("action_required");
+ expect(html).toContain("WHOOP is paused after failing since");
+ expect(primaryActions(html)).toEqual(["resume"]);
+ expect(html).toContain(">Resume<");
+ expect(html).toContain('data-testid="whoop-resume-button"');
+ expect(html).not.toContain("whoop-parked-banner");
+ });
+
+ it("not connected: no badge, one sentence, the BYO form and Connect", () => {
+ const html = render("withings", {
+ connected: false,
+ configured: false,
+ health: null,
+ });
+ expect(html).toMatch(
+ /data-slot="integration-card"[^>]*data-state="not_connected"/,
+ );
+ expect(badgeState(html)).toBeNull();
+ expect(html).toContain("Not connected.");
+ expect(html).toContain('data-testid="withings-setup-guide"');
+ expect(html).toContain('data-testid="withings-redirect-guide"');
+ expect(html).toContain('id="withings-clientid"');
+ expect(html).toContain('data-testid="withings-connect"');
+ // Connecting needs stored credentials first, and the card says so.
+ expect(html).toMatch(
+ /data-testid="withings-connect"[^>]*disabled=""|disabled=""[^>]*data-testid="withings-connect"/,
+ );
+ expect(html).toContain('data-testid="integration-unavailable"');
+ expect(html).not.toContain('data-slot="integration-details-toggle"');
+ });
+
+ it("loading: paints the card and its header, says nothing about state", () => {
+ const html = render("polar", undefined);
+ expect(html).toMatch(
+ /data-slot="integration-card"[^>]*data-state="loading"/,
+ );
+ expect(badgeState(html)).toBeNull();
+ expect(html).not.toContain("Not connected.");
+ });
+
+ it("speaks German with the maintainer's words", () => {
+ const html = render(
+ "oura",
+ connected(
+ healthFixture({
+ state: "degraded",
+ reason: "unexpected_silence",
+ since: hoursAgo(50),
+ }),
+ ),
+ "de",
+ );
+ expect(html).toContain(">Verzögert<");
+ expect(html).toContain("obwohl Oura sonst täglich liefert.");
+ expect(html).toContain("Details für eine Fehlermeldung");
+ });
+});
+
+describe(" menu and actions", () => {
+ it("moves sync, full reload, test, data, credentials, guide and disconnect into the menu", () => {
+ render("withings", connected(healthFixture()));
+ const menu = lastCardProps?.menu ?? [];
+ expect(menu.map((i) => i.id)).toEqual([
+ "sync",
+ "full-sync",
+ "test",
+ "view-data",
+ "credentials",
+ "setup-guide",
+ "disconnect",
+ ]);
+ // The heavy and the destructive entries keep their confirmation.
+ expect(menu.find((i) => i.id === "full-sync")?.confirm).toBeTruthy();
+ const disconnect = menu.find((i) => i.id === "disconnect");
+ expect(disconnect?.destructive).toBe(true);
+ expect(disconnect?.confirm?.confirmLabel).toBe("Disconnect");
+ });
+
+ it("offers no full reload where the provider has none", () => {
+ render("strava", connected(healthFixture()));
+ expect((lastCardProps?.menu ?? []).map((i) => i.id)).not.toContain(
+ "full-sync",
+ );
+ });
+
+ it("names the menu trigger for screen readers and keeps the 44 px floor", () => {
+ const html = render("oura", connected(healthFixture()));
+ expect(html).toContain('aria-label="More actions for Oura"');
+ const trigger = html.match(
+ /
+ ) : null}
+ {editingCredentials ? credentialsForm : null}
+
+
+ );
+}
diff --git a/src/lib/openapi/routes/integrations.ts b/src/lib/openapi/routes/integrations.ts
index 9919a20c4..44862fc08 100644
--- a/src/lib/openapi/routes/integrations.ts
+++ b/src/lib/openapi/routes/integrations.ts
@@ -105,7 +105,7 @@ const syncHealth = z
// Mirrors `ConnectionHealth` in `src/lib/integrations/connection-health.ts`:
// the one server-resolved state per connection every client renders.
-const connectionHealth = z
+export const connectionHealth = z
.object({
state: z
.enum(CONNECTION_HEALTH_STATES)
diff --git a/src/lib/openapi/routes/record-settings.ts b/src/lib/openapi/routes/record-settings.ts
index d8622e60f..fc7dde72d 100644
--- a/src/lib/openapi/routes/record-settings.ts
+++ b/src/lib/openapi/routes/record-settings.ts
@@ -25,6 +25,7 @@ import { z } from "zod/v4";
import { METRIC_BOUNDS } from "@/lib/analytics/effective-range";
import { MANAGED_RECORD_SETTINGS_PATCH_SCHEMAS } from "@/lib/record-settings";
+import { connectionHealth } from "./integrations";
import {
dataEnvelope,
errorEnvelope,
@@ -96,6 +97,11 @@ const recordSettingsIntegrationsResponse = z
]),
lastSuccessAt: z.iso.datetime({ offset: true }).nullable(),
lastAttemptAt: z.iso.datetime({ offset: true }).nullable(),
+ health: connectionHealth
+ .nullable()
+ .describe(
+ "The same server-resolved connection state the owner's own envelope carries, null while the provider is not connected. The web renders this; `state` stays for one transition release.",
+ ),
}),
)
.describe(
@@ -105,7 +111,7 @@ const recordSettingsIntegrationsResponse = z
.meta({
id: "RecordSettingsIntegrationsResponse",
description:
- "Whether the managed record's integrations are connected, and nothing else. Deliberately no control and no credential projection — OAuth and provider state cannot safely carry a record selector, so a Guardian can see that a pipe exists but cannot connect, disconnect, probe or resume it from here. A provider with no real credential reads `disconnected` even when its ledger row says otherwise, because a synthesised healthy ledger is not proof of a connection.",
+ "Whether the managed record's integrations are connected and how each is doing (`health`), and nothing else. Deliberately no control and no credential projection — OAuth and provider state cannot safely carry a record selector, so a Guardian can see that a pipe exists but cannot connect, disconnect, probe or resume it from here. A provider with no real credential reads `disconnected` even when its ledger row says otherwise, because a synthesised healthy ledger is not proof of a connection.",
});
// ── The six family DTOs ──────────────────────────────────────────────
@@ -353,7 +359,7 @@ export const recordSettingsPaths: NonNullable = {
tags: ["Account sharing"],
summary: "Read the managed record's integration connection states",
description:
- "Disclosure only: whether each of the record's integrations is connected and when it last succeeded. There is deliberately no control arm and no credential projection anywhere under this path — a Guardian cannot connect, disconnect, probe or resume a managed record's provider, because an OAuth flow cannot safely carry a record selector. Cookie session or Bearer, while acting on a managed record the caller guards.",
+ "Disclosure only: whether each of the record's integrations is connected, when it last succeeded, and its server-resolved health. There is deliberately no control arm and no credential projection anywhere under this path — a Guardian cannot connect, disconnect, probe or resume a managed record's provider, because an OAuth flow cannot safely carry a record selector. Cookie session or Bearer, while acting on a managed record the caller guards.",
responses: {
...recordRefusal(),
"200": {
diff --git a/tests/integration/record-settings-integrations-health.test.ts b/tests/integration/record-settings-integrations-health.test.ts
new file mode 100644
index 000000000..bbd8a61c6
--- /dev/null
+++ b/tests/integration/record-settings-integrations-health.test.ts
@@ -0,0 +1,125 @@
+/**
+ * A guardian reads the managed record's connections through the same
+ * server-resolved `health` the record's owner would see, and only the
+ * record's: a connection on the guardian's own account must not appear on
+ * the record, and the record's connection must answer from the record's rows.
+ */
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { cookieJar, headerJar } from "./mock-next-headers";
+import { getPrismaClient, truncateAllTables, switchSessionTo } from "./setup";
+
+vi.mock("next/headers", async () => {
+ const { cookieJar, headerJar } = await import("./mock-next-headers");
+ return {
+ headers: vi.fn(async () => ({
+ get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
+ })),
+ cookies: vi.fn(async () => ({
+ get: (name: string) => {
+ const value = cookieJar.get(name);
+ return value ? { name, value } : undefined;
+ },
+ set: (name: string, value: string) => {
+ cookieJar.set(name, value);
+ },
+ delete: (name: string) => {
+ cookieJar.delete(name);
+ },
+ })),
+ };
+});
+
+vi.mock("@/lib/db-compat", () => ({
+ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
+}));
+
+interface ManagedIntegration {
+ integration: string;
+ state: string;
+ health: { state: string; reason: string } | null;
+}
+
+async function readIntegrations(): Promise<{
+ recordId: string;
+ integrations: ManagedIntegration[];
+}> {
+ const { GET } = await import("@/app/api/record-settings/integrations/route");
+ const res = await GET();
+ expect(res.status).toBe(200);
+ return (await res.json()).data;
+}
+
+beforeEach(async () => {
+ await truncateAllTables(getPrismaClient());
+ cookieJar.clear();
+ headerJar.clear();
+});
+
+describe("GET /api/record-settings/integrations", () => {
+ it("publishes the record's own health per provider, never the guardian's", async () => {
+ const prisma = getPrismaClient();
+ const guardian = await prisma.user.create({
+ data: {
+ username: "health-guardian",
+ email: "health-guardian@example.test",
+ role: "USER",
+ timezone: "Europe/Berlin",
+ locale: "en",
+ onboardingCompletedAt: new Date(),
+ // The guardian's own Polar: must not surface on the record.
+ polarAccessTokenEncrypted: "not-a-real-token",
+ },
+ });
+ const { createManagedProfile } =
+ await import("@/lib/managed-profiles/create");
+ const { profile } = await createManagedProfile({
+ creatorId: guardian.id,
+ displayName: "Managed record",
+ dateOfBirth: null,
+ locale: "en",
+ timezone: "UTC",
+ gender: null,
+ });
+ // The record's own Oura, synced once.
+ await prisma.user.update({
+ where: { id: profile.id },
+ data: { ouraAccessTokenEncrypted: "not-a-real-token" },
+ });
+ const { recordSyncSuccess } = await import("@/lib/integrations/status");
+ await recordSyncSuccess(profile.id, "oura");
+
+ const session = await prisma.session.create({
+ data: {
+ userId: guardian.id,
+ expiresAt: new Date(Date.now() + 60_000),
+ },
+ });
+ cookieJar.set("healthlog_session", session.id);
+ await switchSessionTo(session.id, profile.id);
+
+ const data = await readIntegrations();
+ expect(data.recordId).toBe(profile.id);
+ expect(data.integrations).toHaveLength(8);
+
+ const byKey = new Map(data.integrations.map((i) => [i.integration, i]));
+ const oura = byKey.get("oura");
+ expect(oura?.health).not.toBeNull();
+ expect(oura?.health?.state).toBe("ok");
+ // Health and the legacy state agree on "a pipe exists".
+ expect(oura?.state).toBe("connected");
+
+ // The guardian's Polar is the guardian's, not the record's.
+ expect(byKey.get("polar")?.health).toBeNull();
+ for (const key of [
+ "withings",
+ "whoop",
+ "fitbit",
+ "nightscout",
+ "google-health",
+ "strava",
+ ]) {
+ expect(byKey.get(key)?.health, key).toBeNull();
+ }
+ });
+});