From 6ad61cdd1f7e3adf232079c42a1b45bd03b6182f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 18:23:48 +0200 Subject: [PATCH 01/10] fix(shell): take the window's edges and keep the document still The authenticated shell was sized by h-dvh. On iPadOS Safari that value and the area the browser lays out disagree after a rotation and under the floating toolbars: a short value left a strip of page colour under the shell with the last card cut off above it, a tall one let the document scroll by the difference and take the swipes meant for main. The shell now takes the window's edges (absolute inset-0 against the initial containing block), and the document under it gets no scroll and no rubber band (data-scroll="shell": overflow hidden, overscroll none, height 100% so the box survives a dialog's scroll lock making the body relative). Not fixed: a fixed box re-rasterises every page in a layer of its own and traps the bottom bar's z-index. The desktop shell's main now clears the home indicator of an installed app, and the page frame clears the Coach button there with pb-24: the button sits 5.5rem up, so pb-20 left the last 8 px under it. The sub-shell grid floor follows the new padding. --- e2e/settings-overscroll.spec.ts | 4 +- e2e/timeline.spec.ts | 6 +- src/__tests__/pwa-mobile-shell-guard.test.ts | 5 +- src/app/globals.css | 22 ++++ .../layout/__tests__/shell-viewport.test.ts | 119 ++++++++++++++++++ src/components/layout/auth-shell.tsx | 46 +++---- src/components/layout/document-scroll-mode.ts | 36 ++++++ src/components/layout/shell-metrics.ts | 44 ++++++- 8 files changed, 249 insertions(+), 33 deletions(-) create mode 100644 src/components/layout/__tests__/shell-viewport.test.ts create mode 100644 src/components/layout/document-scroll-mode.ts diff --git a/e2e/settings-overscroll.spec.ts b/e2e/settings-overscroll.spec.ts index 2904f7cb8..1a81e69f5 100644 --- a/e2e/settings-overscroll.spec.ts +++ b/e2e/settings-overscroll.spec.ts @@ -8,7 +8,7 @@ import { settleBeforeMeasure } from "./utils/settle"; * v1.25.11 settings fix): the scroll viewport must never extend far past * the last content element. The rule the guard pins is structural — only * the AuthShell owns the scroll height and the bottom padding - * (`pt-6 pb-20` on the wrapper, plus the ``); a page/section must never add its own viewport-height * reserve or bottom gutter, because every such nested reserve stacks on * the shell's own budget and reopens the dark-band-below-the-last-card @@ -45,7 +45,7 @@ const ROUTES = [ ] as const; const VIEWPORTS = [ - { name: "desktop", width: 1440, height: 900, expectedPad: 80 }, // wrapper pb-20 + { name: "desktop", width: 1440, height: 900, expectedPad: 96 }, // wrapper shell-desktop:pb-24 { name: "phone", width: 390, height: 844, expectedPad: 144 }, // pb-20 + main pb-16 ] as const; diff --git a/e2e/timeline.spec.ts b/e2e/timeline.spec.ts index 4a3818413..8bdb4591a 100644 --- a/e2e/timeline.spec.ts +++ b/e2e/timeline.spec.ts @@ -845,9 +845,9 @@ test("the timeline page ends where its content does, at every desktop width", as expect(m.mainSw, where).toBeLessThanOrEqual(m.mainCw + 1); expect(m.docSw, where).toBeLessThanOrEqual(m.docCw + 1); expect(m.docSh, where).toBeLessThanOrEqual(m.docCh + 1); - // The shell's own buffer below the last card (pb-20 and the top - // padding), nothing more. - expect(m.mainSh, where).toBeLessThanOrEqual(m.contentBottom + 104 + 2); + // The shell's own buffer below the last card (the desktop shell's + // pb-24 and the top padding), nothing more. + expect(m.mainSh, where).toBeLessThanOrEqual(m.contentBottom + 120 + 2); } } }); diff --git a/src/__tests__/pwa-mobile-shell-guard.test.ts b/src/__tests__/pwa-mobile-shell-guard.test.ts index f756a599f..e3bc97220 100644 --- a/src/__tests__/pwa-mobile-shell-guard.test.ts +++ b/src/__tests__/pwa-mobile-shell-guard.test.ts @@ -33,7 +33,10 @@ function code(source: string): string { describe("the shell owns the safe area", () => { it("the authenticated shell wrapper takes the insets", () => { const shell = code(read("src/components/layout/auth-shell.tsx")); - expect(shell).toMatch(/className="shell-safe-area flex h-dvh flex-col"/); + expect(shell).toMatch(/
/); + expect(read("src/components/layout/shell-metrics.ts")).toMatch( + /SHELL_ROOT_CLASS =\s*"shell-safe-area absolute inset-0 flex flex-col"/, + ); }); it.each([ diff --git a/src/app/globals.css b/src/app/globals.css index ab29b8f34..5b38c8599 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -615,6 +615,28 @@ body, html[data-scroll="body"] { scrollbar-gutter: stable; } + /* + * The app shell (`data-scroll="shell"`, also set by `AuthShell`) takes the + * window's edges and scrolls inside `
`, so the document has nothing + * to scroll and must not bounce either. On iOS a swipe that reached the end + * of `
` or of the rail chained to the document, the whole page + * rubber-banded and showed the page colour under the shell, and the next + * swipe went to that bounce instead of the list under the finger: the + * "sometimes it does not scroll" a tablet reported. + * + * `height: 100%` gives the body the window's box. The shell is + * `absolute inset-0`, which resolves against the initial containing block + * while the body is static, and against the body once a dialog's scroll + * lock makes it `position: relative` (react-remove-scroll does); an + * auto-height body is 0 px tall and the shell would collapse behind every + * dialog. + */ + html[data-scroll="shell"], + html[data-scroll="shell"] body { + height: 100%; + overflow: hidden; + overscroll-behavior: none; + } body { @apply bg-background text-foreground; } diff --git a/src/components/layout/__tests__/shell-viewport.test.ts b/src/components/layout/__tests__/shell-viewport.test.ts new file mode 100644 index 000000000..d658a879d --- /dev/null +++ b/src/components/layout/__tests__/shell-viewport.test.ts @@ -0,0 +1,119 @@ +/** + * The authenticated shell's geometry decisions, which no browser under test + * can show going wrong: Playwright's WebKit and Chromium have no floating + * toolbar, no rotation and no home indicator, so `100dvh` always equals the + * window there. On an iPad it does not, and that disagreement was the strip + * under the shell and the cut-off card. These hold the decisions that take + * the disagreement out; `e2e/tablet-shell.spec.ts` measures the result. + */ +import { describe, expect, it } from "vitest"; + +import { readSource } from "@/__tests__/helpers/source-files"; + +import { + MAIN_SCROLL_CLASS, + SHELL_ROOT_CLASS, + SUB_SHELL_GRID_FLOOR, +} from "../shell-metrics"; + +const shellSource = () => + readSource("src/components/layout/auth-shell.tsx", [ + "SHELL_ROOT_CLASS", + 'data-slot="main-content-wrapper"', + ]); +/** The page frame's class list: the one `mx-auto max-w-screen-xl` literal. */ +const MAIN_CONTENT_WRAPPER_CLASS = + /"([^"]*\bmx-auto max-w-screen-xl\b[^"]*)"/.exec(shellSource())![1]; +const classes = (value: string) => value.split(/\s+/).filter(Boolean); + +/** Tailwind spacing step or `[calc(env(...)+Nrem)]` offset, in rem. */ +function rem(token: string): number { + const calc = /\+(\d+(?:\.\d+)?)rem\)\]$/.exec(token); + if (calc) return Number(calc[1]); + const step = /-(\d+(?:\.\d+)?)$/.exec(token); + if (step) return Number(step[1]) / 4; + throw new Error(`no length in ${token}`); +} + +describe("the shell takes the window's edges", () => { + it("takes its height from the initial containing block, never from a viewport unit", () => { + const shell = classes(SHELL_ROOT_CLASS); + expect(shell).toEqual( + expect.arrayContaining(["shell-safe-area", "absolute", "inset-0"]), + ); + // `fixed` re-rasterises the whole app in its own layer and traps the + // bottom bar's z-index in a stacking context. + expect(shell).not.toContain("fixed"); + expect(SHELL_ROOT_CLASS).not.toMatch( + /\b(?:min-)?h-(?:dvh|svh|lvh|screen)\b/, + ); + expect(SHELL_ROOT_CLASS).not.toMatch(/\d+(?:d|s|l)?vh/); + }); + + it("AuthShell renders that box and flags the document for it", () => { + const source = shellSource(); + expect(source).toContain("
"); + expect(source).not.toMatch(/shell-safe-area flex h-dvh/); + expect(source).toMatch( + /useDocumentScrollMode\(\s*isBodyScrolled \? "body" : rendersAppShell \? "shell" : null/, + ); + const css = readSource("src/app/globals.css", '[data-scroll="shell"]'); + expect(css).toMatch( + /html\[data-scroll="shell"\],\s*html\[data-scroll="shell"\] body\s*\{\s*height:\s*100%;\s*overflow:\s*hidden;\s*overscroll-behavior:\s*none;/, + ); + }); +}); + +describe("the bottom edge is cleared in both shells", () => { + const fab = readSource( + "src/components/insights/layout-coach-fab.tsx", + 'data-slot="coach-fab"', + ); + const fabSize = rem(/\bsize-(\d+)\b/.exec(fab)![0]); + const phoneFabBottom = rem( + /(? { + expect(main).not.toContain("shell-desktop:pb-0"); + expect(main).toContain( + "shell-desktop:pb-[env(safe-area-inset-bottom,0px)]", + ); + }); + + it("the last line scrolls clear of the Coach button in the phone shell", () => { + const mainPad = Number( + /^pb-\[calc\((\d+(?:\.\d+)?)rem\+env\(safe-area-inset-bottom,0px\)\)\]$/.exec( + main.find((c) => c.startsWith("pb-[calc("))!, + )![1], + ); + const wrapperPad = rem(wrapper.find((c) => /^pb-\d+$/.test(c))!); + expect(mainPad + wrapperPad).toBeGreaterThan(phoneFabBottom + fabSize); + }); + + it("the last line scrolls clear of the Coach button in the desktop shell", () => { + const wrapperPad = rem( + wrapper.find((c) => /^shell-desktop:pb-\d+$/.test(c))!, + ); + expect(wrapperPad).toBeGreaterThan(desktopFabBottom + fabSize); + }); + + it("the sub-shell floor follows the desktop shell's padding", () => { + // Top bar 4rem + pt-6 + the wrapper's bottom padding. + const desktopPad = rem( + wrapper.find((c) => /^shell-desktop:pb-\d+$/.test(c))!, + ); + expect(SUB_SHELL_GRID_FLOOR).toContain( + `shell-desktop:min-h-[calc(100dvh-${4 + 1.5 + desktopPad}rem)]`, + ); + }); +}); diff --git a/src/components/layout/auth-shell.tsx b/src/components/layout/auth-shell.tsx index 18cf95207..2853585d0 100644 --- a/src/components/layout/auth-shell.tsx +++ b/src/components/layout/auth-shell.tsx @@ -38,6 +38,8 @@ import { } from "@/components/layout/module-page-gate"; import { cn } from "@/lib/utils"; import { isFullBleedPage } from "./full-bleed"; +import { useDocumentScrollMode } from "./document-scroll-mode"; +import { MAIN_SCROLL_CLASS, SHELL_ROOT_CLASS } from "./shell-metrics"; import { isOnboardingPathname } from "@/lib/onboarding/wizard-steps"; import { RecordScopeHydrationGate } from "./record-scope-hydration-gate"; import { SidebarNav } from "./sidebar-nav"; @@ -166,25 +168,18 @@ export function AuthShell({ const isOnboardingPage = isOnboardingPathname(pathname); const showUnlockNotifier = isAuthenticated && !isPublicPage && !!user?.id; - // v1.9.0 — the document-level scrollbar-gutter (globals.css) is reserved - // only for the body-scrolled shells (login / register / onboarding / - // standalone legal pages). The authenticated branch is height-locked and - // scrolls inside its own `
`, which reserves its own gutter; applying - // the document gutter there too produced a second, never-painted gutter on - // classic-scrollbar platforms. Flag the body-scrolled branches on `` - // so the CSS rule scopes to them. + // Who scrolls the page, on `` (`document-scroll-mode.ts`). const isBodyScrolled = isPublicPage || isOnboardingPage; - useEffect(() => { - const root = document.documentElement; - if (isBodyScrolled) { - root.setAttribute("data-scroll", "body"); - } else { - root.removeAttribute("data-scroll"); - } - return () => { - root.removeAttribute("data-scroll"); - }; - }, [isBodyScrolled]); + const rendersAppShell = + !isBodyScrolled && + !accessRefused && + !isLoading && + isAuthenticated && + recordSessionTransition.phase === "ready" && + !recordSessionPending; + useDocumentScrollMode( + isBodyScrolled ? "body" : rendersAppShell ? "shell" : null, + ); // v1.25.1 — the authenticated shell keeps `
` // mounted across every route change, so its scroll offset survives @@ -438,9 +433,13 @@ export function AuthShell({ five pixels of content box and pushed the logo and the account menu across its bottom border. `shell-safe-area` paints the strip behind the status bar in the chrome colour (globals.css). The bottom inset - stays with the bottom nav and `
`, which own that edge. + stays with the bottom nav, `
` and the rail, which own that edge. + + The shell takes the window's edges rather than `h-dvh`, and the + document under it neither scrolls nor bounces: `SHELL_ROOT_CLASS` in + `shell-metrics.ts` says why. */} -
+
{/* v1.4.43 — `` paints only when `navigator.onLine === false`. Sits above the maintainership @@ -500,7 +499,7 @@ export function AuthShell({ // for all of them at once instead of asking each new // absolutely-positioned child to remember its own wrapper. className={cn( - "shell-desktop:pb-0 relative flex-1 overflow-y-auto pb-[calc(4rem+env(safe-area-inset-bottom,0px))]", + MAIN_SCROLL_CLASS, fullBleed ? "flex flex-col" : "[scrollbar-gutter:stable]", )} > @@ -516,7 +515,8 @@ export function AuthShell({ v1.16.8 — the bottom padding clears the always-on Coach FAB (48 px button + its offset above the bottom-nav band / the desktop bottom-6 anchor), so the last line of content - can always scroll out from under the floating button. + can always scroll out from under the floating button; the + desktop shell's sits 5.5rem up, so `pb-24` there. */}
{outsideSharedRecord ? ( diff --git a/src/components/layout/document-scroll-mode.ts b/src/components/layout/document-scroll-mode.ts new file mode 100644 index 000000000..e180ec6e9 --- /dev/null +++ b/src/components/layout/document-scroll-mode.ts @@ -0,0 +1,36 @@ +"use client"; + +import { useEffect } from "react"; + +/** + * Who scrolls the page, published on `` as `data-scroll` for the rules + * in `globals.css`. + * + * - `"body"`: the body-scrolled shells (login / register / onboarding / + * standalone legal pages), where the document itself overflows. They get + * the document-level `scrollbar-gutter`. The authenticated branch + * scrolls inside its own `
`, which reserves its own gutter; the + * document gutter there too produced a second, never-painted gutter on + * classic-scrollbar platforms. + * - `"shell"`: the app shell. It takes the window's edges and `
` is its + * one vertical scroller, so the document gets no scroll and no rubber band + * of its own. On iOS a bounce of the document held the next swipe, and the + * list under the finger did not move. + * - `null`: anything else (the refusal panel, the loading states) keeps the + * browser's defaults. + */ +export type DocumentScrollMode = "body" | "shell" | null; + +export function useDocumentScrollMode(mode: DocumentScrollMode): void { + useEffect(() => { + const root = document.documentElement; + if (mode) { + root.setAttribute("data-scroll", mode); + } else { + root.removeAttribute("data-scroll"); + } + return () => { + root.removeAttribute("data-scroll"); + }; + }, [mode]); +} diff --git a/src/components/layout/shell-metrics.ts b/src/components/layout/shell-metrics.ts index f4378cbc3..284c56344 100644 --- a/src/components/layout/shell-metrics.ts +++ b/src/components/layout/shell-metrics.ts @@ -18,15 +18,51 @@ * never drift — Admin previously carried a stale, all-breakpoint * `min-h-[calc(100dvh-12rem)]` column reserve that this de-duplication retires. * - * Budget: top bar 4rem + the AuthShell wrapper's `pt-6` (1.5rem) + `pb-20` - * (5rem) = 10.5rem. Retune here — and only here — if those shell paddings - * change. (Latent follow-up: this constant assumes zero banners above `
`; + * Budget: top bar 4rem + the AuthShell wrapper's `pt-6` (1.5rem) + its bottom + * padding: `pb-20` (5rem) = 10.5rem in the phone shell, `pb-24` (6rem) = + * 11.5rem in the desktop shell (the page frame in `auth-shell.tsx`). Retune here — + * and only here — if those shell paddings change. (Latent follow-up: this constant assumes zero banners above `
`; * a locale/offline/demo banner or an iOS PWA safe-area inset makes it * over-reserve. A fully self-accounting `min-h-full` chain would need the * AuthShell content wrapper to pass a definite height through, which is out of * scope for the shell-only over-scroll fix.) */ -export const SUB_SHELL_GRID_FLOOR = "md:min-h-[calc(100dvh-10.5rem)]"; +export const SUB_SHELL_GRID_FLOOR = + "md:min-h-[calc(100dvh-10.5rem)] shell-desktop:min-h-[calc(100dvh-11.5rem)]"; + +/** + * The authenticated shell's outer box. It takes the window's edges + * (`absolute inset-0` against the initial containing block) rather than + * `h-dvh`, and the top and side safe-area insets (`shell-safe-area`, + * globals.css); the bottom inset belongs to whatever owns the bottom edge in + * each shell. + * + * Not a viewport unit: `h-dvh` is the browser's idea of the visible height, + * and on iPadOS Safari that idea and the area it lays out disagree after a + * rotation and under the floating toolbars. A short value left a strip of + * page colour under the shell with the last card cut off above it; a tall one + * made the document scroll by the difference, and that document scroll took + * the swipes meant for `
`. The initial containing block is the layout + * itself, recomputed with every rotation and resize, and the document never + * scrolls (`data-scroll="shell"`, `document-scroll-mode.ts`), so the box + * cannot drift from the window either. + * + * Not `fixed`: a fixed box gets a compositing layer of its own, which + * re-rasterised the text of every page (the antialiasing moved on every + * screen), and forms a stacking context that would trap the bottom bar's + * z-index under the Coach button and the toasts. + */ +export const SHELL_ROOT_CLASS = + "shell-safe-area absolute inset-0 flex flex-col"; + +/** + * `
`, the shell's one vertical scroller. Phone shell: it clears the + * bottom nav and the home indicator. Desktop shell: there is no bar, and it + * clears the home indicator of an installed app on a tablet (zero in a + * browser tab), so the last line never sits under it. + */ +export const MAIN_SCROLL_CLASS = + "relative flex-1 overflow-y-auto pb-[calc(4rem+env(safe-area-inset-bottom,0px))] shell-desktop:pb-[env(safe-area-inset-bottom,0px)]"; /** * The app-chrome header band: one 4rem strip whose bottom border draws the From 1f4c1bd2f04245c06d0f0a230deff0bd18134085 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 18:23:48 +0200 Subject: [PATCH 02/10] fix(rail): keep every destination reachable under a finger A touch tablet keeps the rail's 44 px rows, so in landscape three to seven destinations sit below the fold, and the list has no scrollbar at rest. The edge fade alone did not read as "swipe here", and a swipe that reached an end chained to the document, which held the next one. The list region moves into its own module. Under a coarse pointer it contains its overscroll, keeps a focused row clear of a new scroll cue, and shows that cue at an edge with more destinations; a tap scrolls the list most of a screen on. The cue stays out of the focus order and the accessibility tree: the keyboard and screen readers already reach every row. The rail takes the home indicator's inset so the account row stays clear of it. A mouse sees no change. --- .../layout/__tests__/sidebar-nav.test.tsx | 62 ++++++ src/components/layout/sidebar-nav-scroll.tsx | 192 ++++++++++++++++++ src/components/layout/sidebar-nav.tsx | 103 ++-------- 3 files changed, 268 insertions(+), 89 deletions(-) create mode 100644 src/components/layout/sidebar-nav-scroll.tsx diff --git a/src/components/layout/__tests__/sidebar-nav.test.tsx b/src/components/layout/__tests__/sidebar-nav.test.tsx index 88809bc95..b123458a2 100644 --- a/src/components/layout/__tests__/sidebar-nav.test.tsx +++ b/src/components/layout/__tests__/sidebar-nav.test.tsx @@ -63,6 +63,7 @@ import { readSidebarCollapsedPref, writeSidebarCollapsedPref, } from "../sidebar-nav"; +import { RAIL_NAV_SCROLL, RailScrollCue } from "../sidebar-nav-scroll"; import { ADMIN_SECTIONS } from "@/components/admin/admin-shell"; import { visibleUtilityDestinations } from "../nav-model"; import { delegatedDomains } from "@/lib/sharing/domain-write-support"; @@ -577,3 +578,64 @@ describe(" carries no rules for a viewport it never shows on", () => expect(source).not.toMatch(/pointer:coarse\)_and_\(height<=520px\)/); }); }); + +describe(" keeps every destination reachable on a touch tablet", () => { + function navClass(html: string): string { + return ( + /]*aria-label="[^"]*"[^>]*class="([^"]*)"/.exec(html)?.[1] ?? "" + ); + } + + it("scrolls the list inside the rail and keeps the swipe there", () => { + const nav = navClass(render()).replaceAll("&", "&").split(" "); + expect(RAIL_NAV_SCROLL.split(" ")).toEqual( + expect.arrayContaining(["min-h-0", "flex-1", "overflow-y-auto"]), + ); + for (const cls of RAIL_NAV_SCROLL.split(" ")) expect(nav).toContain(cls); + // Without it a swipe at either end chained to the document on iOS. + expect(nav).toContain("pointer-coarse:overscroll-contain"); + // A focused row lands clear of the 44 px cue under a finger. + expect(nav).toContain("pointer-coarse:scroll-py-12"); + }); + + it("gives the rail the home indicator's inset, so the avatar row stays clear", () => { + const aside = /]*class="([^"]*)"/.exec(render())?.[1] ?? ""; + expect(aside.split(" ")).toContain("pb-[env(safe-area-inset-bottom,0px)]"); + }); + + it("draws the scroll cue for a finger only, outside the focus order and the accessibility tree", () => { + for (const direction of ["up", "down"] as const) { + const html = renderToStaticMarkup( + , + ); + expect(html).toContain('data-slot="sidebar-nav-scroll-cue"'); + expect(html).toContain(`data-direction="${direction}"`); + expect(html).toContain('tabindex="-1"'); + expect(html).toContain('aria-hidden="true"'); + const cls = /class="([^"]*)"/.exec(html)![1].split(" "); + expect(cls).toEqual( + expect.arrayContaining(["hidden", "pointer-coarse:flex"]), + ); + // 44 px touch floor. + expect(cls).toContain("h-11"); + } + }); + + it("scrolls the list most of a screen on per tap", () => { + const calls: ScrollToOptions[] = []; + const nav = { + clientHeight: 400, + scrollBy: (o: ScrollToOptions) => calls.push(o), + } as unknown as HTMLElement; + vi.stubGlobal("window", { + matchMedia: () => ({ matches: true }), + }); + const element = RailScrollCue({ + direction: "down", + navRef: { current: nav }, + }) as React.ReactElement<{ onClick: () => void }>; + element.props.onClick(); + vi.unstubAllGlobals(); + expect(calls).toEqual([{ top: 300, behavior: "auto" }]); + }); +}); diff --git a/src/components/layout/sidebar-nav-scroll.tsx b/src/components/layout/sidebar-nav-scroll.tsx new file mode 100644 index 000000000..262d75a44 --- /dev/null +++ b/src/components/layout/sidebar-nav-scroll.tsx @@ -0,0 +1,192 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { ChevronDown, ChevronUp } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +/** + * The rail's destination list as a scroll region: the one part of the rail + * that gives up height, between the pinned logo band above and the pinned + * footer group and account row below (`sidebar-nav.tsx`). + */ + +/** + * The nav list's scroll region. `scroll-py-6` keeps a focused row clear of + * the edge fades; under a finger `scroll-py-12` keeps it clear of the scroll + * cue as well, which is 44 px tall. `overscroll-contain` is the own-scroll + * list rule (ui-standards §2), taken under a finger, where the swipe that + * reaches an end is the problem. A mouse or trackpad has nothing to chain + * to (the app shell's document neither scrolls nor bounces), and on Chromium + * the property moves the list into a layer of its own, which re-rasterised + * every label in the rail by a shade. + */ +export const RAIL_NAV_SCROLL = + "min-h-0 flex-1 scroll-py-6 pointer-coarse:scroll-py-12 overflow-y-auto pointer-coarse:overscroll-contain [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"; + +/** + * The scroll cue at an edge of the nav list that has more destinations + * beyond it, under a finger only. + * + * A touch tablet keeps the 44 px rows, so in landscape (an iPad is 670 to + * 830 px tall in Safari) three to seven destinations sit below the fold, and + * the list has no scrollbar: iOS paints none on an element at rest. The fade + * alone did not read as "swipe here", and the rail looked as if it simply + * ended. The cue says so and is a way there in itself: a tap scrolls the + * list most of a screen on. + * + * It is a pointer shortcut and nothing else. The keyboard reaches every row + * by Tab (focus scrolls the row into view) and a screen reader reads the + * list whole, so the cue stays out of the focus order and the accessibility + * tree. A mouse scrolls with the wheel and never sees it. + */ +export function RailScrollCue({ + direction, + navRef, +}: { + direction: "up" | "down"; + navRef: React.RefObject; +}) { + const Icon = direction === "down" ? ChevronDown : ChevronUp; + return ( + + ); +} + +/** Height of the fade at a scroll edge that has more content beyond it. */ +const RAIL_FADE = "1.5rem"; + +/** + * The fade mask for the list's scroll edges: a top fade while there is + * content above, a bottom fade while there is content below. A mask paints + * alpha only, so it reads the same in light and dark and never moves layout. + */ +function railFadeMask( + top: boolean, + bottom: boolean, +): React.CSSProperties | undefined { + if (!top && !bottom) return undefined; + const image = `linear-gradient(to bottom, ${top ? "transparent" : "#000"} 0, #000 ${top ? RAIL_FADE : "0px"}, #000 calc(100% - ${bottom ? RAIL_FADE : "0px"}), ${bottom ? "transparent" : "#000"} 100%)`; + return { maskImage: image, WebkitMaskImage: image }; +} + +/** + * Whether the nav list is taller than the room the rail leaves it, i.e. + * whether it scrolls, and which of its edges have more content beyond them. + * Re-measured when the nav (viewport height) or the list (entries arriving + * as modules resolve) changes size, and on scroll. + */ +export function useNavOverflows(navRef: React.RefObject): { + overflows: boolean; + moreAbove: boolean; + moreBelow: boolean; +} { + const [edges, setEdges] = useState({ + overflows: false, + moreAbove: false, + moreBelow: false, + }); + useEffect(() => { + const nav = navRef.current; + if (!nav || typeof ResizeObserver === "undefined") return; + // Strict: any overflow, the same test that makes the list scroll. + const update = () => { + const overflows = nav.scrollHeight > nav.clientHeight; + const moreAbove = overflows && nav.scrollTop > 0.5; + const moreBelow = + overflows && nav.scrollTop + nav.clientHeight < nav.scrollHeight - 0.5; + setEdges((prev) => + prev.overflows === overflows && + prev.moreAbove === moreAbove && + prev.moreBelow === moreBelow + ? prev + : { overflows, moreAbove, moreBelow }, + ); + }; + const observer = new ResizeObserver(update); + observer.observe(nav); + if (nav.firstElementChild) observer.observe(nav.firstElementChild); + nav.addEventListener("scroll", update, { passive: true }); + update(); + return () => { + observer.disconnect(); + nav.removeEventListener("scroll", update); + }; + }, [navRef]); + return edges; +} + +/** + * The list's region: the `
- + {/* Bottom utility links — the shared utility tail (minus Notifications, which lives in the avatar menu) with the From 22976337cbe44b71adf148735a93b32b0d3d77d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 18:23:49 +0200 Subject: [PATCH 03/10] fix(tabs): leave the vertical pan to the page touch-pan-x on the tab strip refused a vertical pan that began on it, so a swipe up or down over a tab strip scrolled nothing. The strip already pans sideways through its own overflow. --- src/components/ui/__tests__/tabs.test.tsx | 17 +++++++++++++++++ src/components/ui/tabs.tsx | 11 ++++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/components/ui/__tests__/tabs.test.tsx b/src/components/ui/__tests__/tabs.test.tsx index 785c49966..c3c3ccb1a 100644 --- a/src/components/ui/__tests__/tabs.test.tsx +++ b/src/components/ui/__tests__/tabs.test.tsx @@ -30,4 +30,21 @@ describe("", () => { // Horizontal scroll behaviour is still intact for long strips. expect(list![0]).toMatch(/\boverflow-x-auto\b/); }); + + it("leaves the vertical pan to the page", () => { + // `touch-pan-x` (or `touch-none`) on the strip refuses a vertical pan + // that starts on it, so a swipe up or down over a tab strip scrolled + // nothing. The strip pans sideways through its own overflow. + const html = renderToStaticMarkup( + + + A + + , + ); + const list = html.match(/]*data-slot="tabs-list"[^>]*>/)![0]; + expect(list).not.toMatch( + /\btouch-(?:pan-x|pan-left|pan-right|none|pinch-zoom)\b/, + ); + }); }); diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx index 1084d57be..7a2e1a78b 100644 --- a/src/components/ui/tabs.tsx +++ b/src/components/ui/tabs.tsx @@ -29,8 +29,8 @@ function Tabs({ // mobile when more than ~3 tabs render at typical label widths // (`/insights`, `/admin`, `/settings/*`). Tab labels are i18n-driven // so we cannot truncate them deterministically. Instead, the list -// becomes horizontally scrollable on mobile via `overflow-x-auto` -// plus `touch-pan-x` for the iOS PWA, and the wrapping tabs preserve +// becomes horizontally scrollable on mobile via `overflow-x-auto`, and the +// wrapping tabs preserve // `whitespace-nowrap` so labels never word-wrap mid-tab. Desktop // looks identical because the inline-flex shrink-wraps inside the // wider container. @@ -47,10 +47,15 @@ function Tabs({ // Padding sits at `p-1` (4 px) so the 3 px focus-visible ring // (`focus-visible:ring-[3px]`) on triggers stays inside the // `overflow-y-hidden` clamp without clipping the bottom of the ring. +// No `touch-action` on the strip: `overflow-x-auto` already pans it sideways +// under a finger, and the `touch-pan-x` it once carried also refused the +// vertical pan, so a swipe up or down that began on a tab strip scrolled +// nothing at all (on a tablet the strip is 52 px of the page under the 44 px +// touch floor). // Under a coarse pointer the strip sizes to its triggers, which take the // 44 px touch floor; a mouse keeps the compact 36 px strip. const tabsListVariants = cva( - "rounded-lg p-1 group-data-[orientation=horizontal]/tabs:h-9 pointer-coarse:group-data-[orientation=horizontal]/tabs:h-auto data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit max-w-full items-center justify-center overflow-x-auto overflow-y-hidden touch-pan-x scrollbar-thin group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col group-data-[orientation=vertical]/tabs:overflow-x-visible group-data-[orientation=vertical]/tabs:overflow-y-visible", + "rounded-lg p-1 group-data-[orientation=horizontal]/tabs:h-9 pointer-coarse:group-data-[orientation=horizontal]/tabs:h-auto data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit max-w-full items-center justify-center overflow-x-auto overflow-y-hidden scrollbar-thin group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col group-data-[orientation=vertical]/tabs:overflow-x-visible group-data-[orientation=vertical]/tabs:overflow-y-visible", { variants: { variant: { From 798747f987ba48aef514d132eed9bada8644dbab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 18:23:49 +0200 Subject: [PATCH 04/10] test(e2e): walk the app shell at every tablet size tablet-shell.spec.ts runs on WebKit with an iPad profile (webkit-tablet, in the WebKit job) and on Chromium with an Android tablet profile (chromium-tablet, in the shards), across iPad mini to iPad Pro 13 in both orientations, Safari's bars, Split View and Slide Over. It checks that the shell fills the window and leaves the document nothing to scroll, that every rail destination scrolls into view on top and takes a tap, that the scroll cue follows the overflow, that main reaches its last line clear of the bottom bar, that the page scrolls again after a sheet and a dialog close, and that a tab strip leaves the vertical pan to the page. ui-standards records the shell and rail rules. --- .github/workflows/e2e.yml | 9 +- docs/contributing/ui-standards.md | 8 +- e2e/tablet-shell.spec.ts | 420 ++++++++++++++++++++++++++++++ playwright.config.ts | 25 ++ 4 files changed, 455 insertions(+), 7 deletions(-) create mode 100644 e2e/tablet-shell.spec.ts diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 3710d15fc..165aca665 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -62,7 +62,7 @@ jobs: AUTH_RP_ORIGIN: http://localhost:3000 SESSION_COOKIE_SECURE: "false" NODE_ENV: production - # The WebKit spec runs in the `webkit` job below. + # The WebKit specs run in the `webkit` job below. E2E_SKIP_WEBKIT: "1" steps: @@ -146,8 +146,9 @@ jobs: if [ -z "$version" ]; then echo "playwright-core not found in pnpm-lock.yaml"; exit 1; fi echo "version=$version" >> "$GITHUB_OUTPUT" - # The WebKit spec (`bottom-sheet.spec.ts`, project `webkit-iphone`), in the - # same image as the shards. + # The WebKit specs, in the same image as the shards: the bottom sheet on an + # iPhone (`bottom-sheet.spec.ts`, project `webkit-iphone`) and the app shell + # on an iPad (`tablet-shell.spec.ts`, project `webkit-tablet`). webkit: runs-on: ubuntu-24.04 needs: playwright-version @@ -202,7 +203,7 @@ jobs: - run: NODE_OPTIONS=--max-old-space-size=8192 pnpm build - - run: pnpm exec playwright test --project=webkit-iphone + - run: pnpm exec playwright test --project=webkit-iphone --project=webkit-tablet env: E2E_BASE_URL: http://localhost:3000 diff --git a/docs/contributing/ui-standards.md b/docs/contributing/ui-standards.md index fe3074434..ffed43557 100644 --- a/docs/contributing/ui-standards.md +++ b/docs/contributing/ui-standards.md @@ -32,10 +32,10 @@ The gaps this section used to list are closed: `TileHeader` carries a `size="sm" ## 2. Spacing scale -> **Scroll height is shell-owned.** Only `AuthShell` owns the scroll container's height and bottom padding (`pb-20` wrapper + ``). Pages/sections never add viewport-height reserves (`min-h-dvh`, `min-h-[calc(100dvh-…)]`), `h-full` chains, or bottom gutters inside the scrolling `
`; nested reserves stack on the shell budget and scroll past the last card. One sanctioned exception: the settings-shell grid floor (budget-derived, documented; retune it when shell paddings change). Lists that need their own scrolling use `max-h-* overflow-y-auto overscroll-contain`. Guard: `e2e/settings-overscroll.spec.ts`. +> **Scroll height is shell-owned.** Only `AuthShell` owns the scroll container's height and bottom padding (`pb-20` wrapper, `pb-24` in the desktop shell, + the phone shell's bottom-nav clearance on `
`). The shell itself takes the window's edges (`absolute inset-0`), never `h-dvh`, and `
` is the one vertical scroller: the document neither scrolls nor bounces under it. Pages/sections never add viewport-height reserves (`min-h-dvh`, `min-h-[calc(100dvh-…)]`), `h-full` chains, or bottom gutters inside the scrolling `
`; nested reserves stack on the shell budget and scroll past the last card. One sanctioned exception: the settings-shell grid floor (budget-derived, documented; retune it when shell paddings change). Lists that need their own scrolling use `max-h-* overflow-y-auto overscroll-contain`. Guard: `e2e/settings-overscroll.spec.ts`. - **Allowed steps:** `1 / 1.5 / 2 / 2.5 / 3 / 3.5 / 4 / 6 / 8 / 10`. `2.5` and `3.5` are allowed: the medication card's rhythm is built on them, and the in-between steps earn their keep in dense card interiors. `5` (20 px), as in `py-5` or `p-5`, is not allowed; replace it when you touch a file that still has it. `7` (28 px) is not allowed either: it was the old Settings and Admin `pl-7` body gutter, which is gone (§13). -- **Page frame:** exactly one authenticated container, owned by `AuthShell` (`layout/auth-shell.tsx:318`): `mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6`. Never re-implement it in a page; full-bleed pages opt out via a shell flag, not negative margins. Prose pages (about/privacy/legal): `mx-auto max-w-3xl px-4 py-8 md:px-6 md:py-12`. +- **Page frame:** exactly one authenticated container, owned by `AuthShell` (`MAIN_CONTENT_WRAPPER_CLASS` in `layout/shell-metrics.ts`): `mx-auto max-w-screen-xl px-4 pt-6 pb-20 md:px-6 shell-desktop:pb-24`. Never re-implement it in a page; full-bleed pages opt out via a shell flag, not negative margins. Prose pages (about/privacy/legal): `mx-auto max-w-3xl px-4 py-8 md:px-6 md:py-12`. - **Page rhythm:** root of every page is `space-y-6`. Grouped card sections: `space-y-10` between groups, `space-y-4` inside a group (notifications/integrations pattern). Insights sub-pages join `space-y-6`. - **Left reading edge:** page-level text (H1, explainer, section headings) sits at the container edge. Only surfaces that paint a visible border/background may inset their content (`px-4 md:px-6`). A block without a border must not carry horizontal padding. - **Grids:** grids **of** cards/tiles = `gap-4`; value read-outs **inside** one card = `gap-x-3 gap-y-2`; form field grids = `gap-4`. @@ -148,7 +148,7 @@ Forbidden on any Insights card: a hand-rolled icon+title row, a card title centr ## 9. One scroll floor; sub-shells never re-declare bottom-space -Reinforces §2's "scroll height is shell-owned". Only `AuthShell` owns the scroll container's bottom padding and any viewport-height reserve: the `
` clears the mobile bottom-nav (`pb-[calc(4rem+safe-area)]`) and its wrapper clears the Coach FAB (`pb-20`). **No page, section, or sub-shell** adds a `min-h-[calc(100dvh-…)]` / `min-h-dvh` reserve or a `pb-*` bottom gutter inside the scrolling `
`; a nested reserve stacks on the shell budget and scrolls past the last card. +Reinforces §2's "scroll height is shell-owned". Only `AuthShell` owns the scroll container's bottom padding and any viewport-height reserve: the `
` clears the mobile bottom-nav (`pb-[calc(4rem+safe-area)]`), or the home indicator in the desktop shell, and its wrapper clears the Coach FAB (`pb-20`, `pb-24` in the desktop shell). **No page, section, or sub-shell** adds a `min-h-[calc(100dvh-…)]` / `min-h-dvh` reserve or a `pb-*` bottom gutter inside the scrolling `
`; a nested reserve stacks on the shell budget and scrolls past the last card. A two-column sub-shell that needs a "don't jump while loading" floor derives it from **one shared source**: `SUB_SHELL_GRID_FLOOR` (`src/components/layout/shell-metrics.ts`); applied to the **grid** (not a column), so the `1fr` content row stretches to fill without over-shooting. It never re-declares the floor per-shell (`AdminShell` drifting to a stale 12rem column reserve is exactly how this recurred). Guard: `e2e/settings-overscroll.spec.ts` covers Settings **and** Admin and measures against each element's content-box bottom, so a redundant sub-shell `pb-*` fails instead of inflating the allowed budget. @@ -156,6 +156,8 @@ A two-column sub-shell that needs a "don't jump while loading" floor derives it Settings + Notifications are **account utilities, not feature destinations**. They live ONLY in the user/avatar menu (mobile top-bar `DropdownMenu`; desktop sidebar avatar menu, with Settings additionally in the sidebar footer). The mobile "More" overflow hub carries **feature destinations only** (Measurements, Mood, Cycle, Labs, Coach, …), never Settings or Notifications. The hub is `mobileMoreHubDestinations()` = `visibleNavDestinations` minus the primary slots (`/`, `/medications`, `/insights`); it does **not** append the utility tail. `NAV_UTILITY_DESTINATIONS` (`visibleUtilityDestinations()`) feeds the sidebar footer + user menus, not the mobile overflow. Never surface a utility in two menus reachable from the same screen. +The rail's destination list is the one part of the rail that gives up height: the logo band, the footer group and the account row stay pinned, and the list scrolls between them (`overscroll-contain`, no native scrollbar, fades at an edge with more). A mouse gets the height-stepped row density; a finger keeps the 44 px rows, and at an edge with more destinations the rail shows a scroll cue (`sidebar-nav-scroll-cue`) that a tap follows. Guard: `e2e/tablet-shell.spec.ts`. + ## 11. Inline action row; the PageHeader pattern A title/sentence with an adjacent action is one row, not a wrapping stack: `flex items-start justify-between gap-3`, the text `min-w-0 flex-1` (wraps internally), the action `shrink-0` (stays beside the text, never pushed to its own line). This is the canonical `PageHeader` / `TileHeader` (`right` slot) shape; reuse it (e.g. the dashboard-hero verdict + CTA) instead of a one-off `flex-wrap` row whose `gap-y-*` drops the action below on a full sentence. Keep the mobile tap-target floor (`min-h-11 sm:min-h-9`). diff --git a/e2e/tablet-shell.spec.ts b/e2e/tablet-shell.spec.ts new file mode 100644 index 000000000..5dfd258a2 --- /dev/null +++ b/e2e/tablet-shell.spec.ts @@ -0,0 +1,420 @@ +import type { Locator, Page } from "@playwright/test"; + +import { mockDay } from "./setup/day-mock"; +import { expect, test } from "./setup/test"; +import { MOBILE_ROUTES_STORAGE_STATE_PATH } from "./setup/global-setup"; + +/** + * The app shell on a tablet, under a finger, at every iPad size in both + * orientations: the rail reaches every destination, `
` scrolls to its + * last line, and closing a sheet or a dialog hands the scroll back. + * + * Runs in `webkit-tablet` (iPad, the engine an iPad runs; CI's WebKit job) + * and `chromium-tablet` (an Android tablet, in the Chromium shards). Both + * report `pointer: coarse`, so the rail keeps its 44 px rows and its list + * overflows in landscape, which is the case that broke. + * + * What a browser under test cannot show is the iPadOS toolbar moving the + * visible height away from `100dvh`; `shell-viewport.test.ts` holds the + * decisions that take that disagreement out, and this file measures that the + * shell they produce fills the window exactly and leaves the document + * nothing to scroll or bounce. + * + * Stable hooks only: `sidebar-nav-region`, `sidebar-nav-scroll-cue`, + * `sidebar-footer`, `main-content-wrapper`, `bottom-nav`, + * `bottom-nav-more` (test id), `command-palette-trigger`, + * `command-palette-input`, `day-panel`. + */ + +/** iPad and Android tablet sizes, CSS px, with the browser's own bars where they take room. */ +const VIEWPORTS = [ + { name: "iPad mini portrait", width: 744, height: 1133 }, + { name: "iPad mini landscape", width: 1133, height: 744 }, + { name: "iPad mini landscape, Safari bars", width: 1133, height: 670 }, + { name: "iPad landscape, Safari bars", width: 1000, height: 690 }, + { name: "iPad Air 11 portrait", width: 820, height: 1180 }, + { name: "iPad Air 11 landscape", width: 1180, height: 820 }, + { name: "iPad Pro 11 portrait", width: 834, height: 1194 }, + { name: "iPad Pro 11 landscape", width: 1194, height: 834 }, + { name: "iPad Pro 13 portrait", width: 1024, height: 1366 }, + { name: "iPad Pro 13 landscape", width: 1366, height: 1024 }, + { name: "Split View two thirds", width: 694, height: 1024 }, + { name: "Split View half, landscape", width: 590, height: 820 }, + { name: "Slide Over", width: 375, height: 1024 }, + { name: "Android tablet landscape", width: 1138, height: 712 }, +] as const; + +test.use({ storageState: MOBILE_ROUTES_STORAGE_STATE_PATH }); + +async function openDashboard(page: Page) { + await page.goto("/", { waitUntil: "domcontentloaded" }); + await expect( + page.locator('#main-content [data-slot="main-content-wrapper"]'), + ).toBeVisible(); + // The module-gated destinations join the rail once `/api/auth/me` lands. + await expect(page.locator('aside nav a[href="/medications"]')).toHaveCount(1); +} + +/** The shell fills the window exactly and the document has nothing to scroll. */ +async function expectShellPinned(page: Page) { + const m = await page.evaluate(() => { + const shell = document.querySelector(".shell-safe-area")!; + const rect = shell.getBoundingClientRect(); + const root = document.scrollingElement!; + const html = getComputedStyle(document.documentElement); + return { + top: rect.top, + bottom: rect.bottom, + left: rect.left, + right: rect.right, + innerHeight, + innerWidth, + docScroll: root.scrollHeight - root.clientHeight, + overflowY: html.overflowY, + overscroll: html.overscrollBehaviorY, + }; + }); + expect(m.top).toBe(0); + expect(m.left).toBe(0); + expect(Math.abs(m.bottom - m.innerHeight)).toBeLessThanOrEqual(0.5); + expect(Math.abs(m.right - m.innerWidth)).toBeLessThanOrEqual(0.5); + expect(m.docScroll).toBeLessThanOrEqual(0); + expect(m.overflowY).toBe("hidden"); + // No rubber band on the document: on iOS it held the next swipe. + expect(m.overscroll).toBe("none"); +} + +function railNav(page: Page): Locator { + return page.locator('aside [data-slot="sidebar-nav-region"] > nav'); +} + +/** Scrolls `
` to its end, waiting out content that grows on the way. */ +async function scrollMainToEnd(page: Page) { + let last = -1; + for (let i = 0; i < 8; i += 1) { + const height = await page.evaluate(() => { + const main = document.getElementById("main-content")!; + main.scrollTop = main.scrollHeight; + return main.scrollHeight; + }); + if (height === last) return; + last = height; + await page.waitForTimeout(250); + } +} + +/** Where the page's lowest content ends, and what covers the window's bottom edge. */ +async function measureMainEnd(page: Page) { + return page.evaluate(() => { + const main = document.getElementById("main-content")!; + const wrapper = main.querySelector( + '[data-slot="main-content-wrapper"]', + )!; + let contentBottom = 0; + for (const el of wrapper.querySelectorAll("*")) { + const cs = getComputedStyle(el); + if (cs.position === "fixed" || cs.visibility === "hidden") continue; + const rect = el.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) continue; + let p = el.parentElement; + let nested = false; + while (p && p !== main) { + const ps = getComputedStyle(p); + if ( + /(auto|scroll|hidden)/.test(ps.overflowY) && + p.scrollHeight > p.clientHeight + 1 + ) { + nested = true; + break; + } + p = p.parentElement; + } + if (nested) continue; + contentBottom = Math.max(contentBottom, rect.bottom); + } + const visible = (el: Element | null): el is HTMLElement => + !!el && + getComputedStyle(el).display !== "none" && + el.getBoundingClientRect().height > 0; + const bar = document.querySelector('[data-slot="bottom-nav"]'); + const mainRect = main.getBoundingClientRect(); + return { + atEnd: main.scrollTop + main.clientHeight >= main.scrollHeight - 1, + scrolls: main.scrollHeight > main.clientHeight, + contentBottom, + mainBottom: mainRect.bottom, + barTop: visible(bar) ? bar.getBoundingClientRect().top : null, + }; + }); +} + +for (const vp of VIEWPORTS) { + test.describe(`${vp.name} (${vp.width}x${vp.height})`, () => { + test.beforeEach(async ({ page }) => { + await page.setViewportSize({ width: vp.width, height: vp.height }); + }); + + test("every rail destination scrolls into view and takes a tap", async ({ + page, + }) => { + await openDashboard(page); + await expectShellPinned(page); + + const aside = page.locator("aside"); + const railShown = vp.width >= 768; + if (!railShown) { + // Split View and Slide Over get the phone shell: the bar, no rail. + await expect(aside).toBeHidden(); + await expect(page.locator('[data-slot="bottom-nav"]')).toBeVisible(); + return; + } + await expect(aside).toBeVisible(); + + // Nothing in the rail is clipped by the rail: the logo band, the list + // region, the pinned footer and the account row all sit inside it, in + // that order, and the list region alone gives up height. + const parts = await page.evaluate(() => { + const asideEl = document.querySelector("aside")!; + const box = asideEl.getBoundingClientRect(); + const pick = (sel: string) => + asideEl.querySelector(sel)!.getBoundingClientRect(); + const column = asideEl.querySelector('[data-slot="sidebar-column"]')!; + const user = column.lastElementChild!.getBoundingClientRect(); + return { + clipped: asideEl.scrollHeight - asideEl.clientHeight, + asideTop: box.top, + asideBottom: box.bottom, + header: pick('[data-slot="sidebar-header"]'), + region: pick('[data-slot="sidebar-nav-region"]'), + footer: pick('[data-slot="sidebar-footer"]'), + user, + overscroll: getComputedStyle( + asideEl.querySelector('[data-slot="sidebar-nav-region"] > nav')!, + ).overscrollBehaviorY, + }; + }); + expect(parts.clipped).toBeLessThanOrEqual(0); + expect(parts.header.top).toBeGreaterThanOrEqual(parts.asideTop - 0.5); + expect(parts.region.top).toBeGreaterThanOrEqual( + parts.header.bottom - 0.5, + ); + expect(parts.footer.top).toBeGreaterThanOrEqual( + parts.region.bottom - 0.5, + ); + expect(parts.user.top).toBeGreaterThanOrEqual(parts.footer.bottom - 0.5); + expect(parts.user.bottom).toBeLessThanOrEqual(parts.asideBottom + 0.5); + expect(parts.overscroll).toBe("contain"); + await expect( + aside.locator('[data-slot="sidebar-collapse-toggle"]'), + ).toBeInViewport({ ratio: 1 }); + await expect( + aside.locator('[data-slot="nav-settings-link"]'), + ).toBeInViewport({ ratio: 1 }); + + // Every destination: scrolled into the list's view, whole, on top + // (not under the cue, the footer or anything else), and actionable. + const nav = railNav(page); + const links = nav.locator("a[href]"); + const count = await links.count(); + expect(count).toBeGreaterThanOrEqual(10); + for (let i = 0; i < count; i += 1) { + const link = links.nth(i); + await link.scrollIntoViewIfNeeded(); + await expect + .poll(() => + link.evaluate((el) => { + const navEl = el.closest("nav")!; + const r = el.getBoundingClientRect(); + const n = navEl.getBoundingClientRect(); + const hit = document.elementFromPoint( + r.left + r.width / 2, + r.top + r.height / 2, + ); + return ( + r.top >= n.top - 0.5 && + r.bottom <= n.bottom + 0.5 && + !!hit && + el.contains(hit) + ); + }), + ) + .toBe(true); + await link.click({ trial: true }); + } + + // A list taller than its region shows the touch cue at the edge that + // has more, and a tap on it is a way there. + const overflows = await nav.evaluate( + (el) => el.scrollHeight > el.clientHeight + 1, + ); + const down = page.locator( + '[data-slot="sidebar-nav-scroll-cue"][data-direction="down"]', + ); + const up = page.locator( + '[data-slot="sidebar-nav-scroll-cue"][data-direction="up"]', + ); + await nav.evaluate((el) => (el.scrollTop = 0)); + if (overflows) { + await expect(down).toBeVisible(); + await expect(up).toHaveCount(0); + await down.tap(); + await expect + .poll(() => nav.evaluate((el) => el.scrollTop)) + .toBeGreaterThan(0); + await expect(up).toBeVisible(); + await nav.evaluate((el) => (el.scrollTop = el.scrollHeight)); + await expect(down).toHaveCount(0); + } else { + await expect(down).toHaveCount(0); + await expect(up).toHaveCount(0); + } + + // And the last destination is a real tap away. + const lastLink = links.nth(count - 1); + const href = await lastLink.getAttribute("href"); + await lastLink.scrollIntoViewIfNeeded(); + await lastLink.tap(); + await expect(page).toHaveURL(new RegExp(`${href}$`)); + }); + + // The Coach button's clearance is held by `shell-viewport.test.ts`: the + // button renders only with an AI provider, which the suite's accounts + // do not have, so a check here would never run. + test("main scrolls to its last line, clear of the bottom bar", async ({ + page, + }) => { + await openDashboard(page); + await scrollMainToEnd(page); + const end = await measureMainEnd(page); + expect(end.scrolls).toBe(true); + expect(end.atEnd).toBe(true); + expect(end.contentBottom).toBeLessThanOrEqual(end.mainBottom + 0.5); + if (end.barTop !== null) { + expect(end.contentBottom).toBeLessThanOrEqual(end.barTop); + } + await expectShellPinned(page); + }); + }); +} + +/** After a modal closes, the page behind takes pointer and wheel again and scrolls. */ +async function expectPageScrollsAgain(page: Page, browserName: string) { + await expect + .poll(() => + page.evaluate(() => ({ + locked: document.body.hasAttribute("data-scroll-locked"), + pointer: getComputedStyle(document.body).pointerEvents, + })), + ) + .toEqual({ locked: false, pointer: "auto" }); + const before = await page.evaluate(() => { + const main = document.getElementById("main-content")!; + main.scrollTop = 0; + const r = main.getBoundingClientRect(); + const x = r.left + r.width / 2; + const y = r.top + r.height / 2; + const hit = document.elementFromPoint(x, y); + const wheel = new WheelEvent("wheel", { + deltaY: 120, + bubbles: true, + cancelable: true, + clientX: x, + clientY: y, + }); + hit?.dispatchEvent(wheel); + return { + onMain: !!hit && main.contains(hit), + blocked: wheel.defaultPrevented, + x, + y, + }; + }); + // Nothing invisible lies over `
`, and no scroll lock swallows the + // wheel (react-remove-scroll cancels it outside its own layer). + expect(before.onMain).toBe(true); + expect(before.blocked).toBe(false); + // A real wheel where the engine has one. Mobile WebKit under Playwright + // has none (and no native touch scroll), so there the two checks above + // are the proof: nothing covers `
` and nothing cancels its events. + if (browserName === "webkit") return; + await page.mouse.move(before.x, before.y); + await page.mouse.wheel(0, 600); + await expect + .poll(() => + page.evaluate(() => document.getElementById("main-content")!.scrollTop), + ) + .toBeGreaterThan(0); +} + +const MODAL_VIEWPORTS = [ + { name: "iPad Air 11 landscape", width: 1180, height: 820 }, + { name: "iPad Air 11 portrait", width: 820, height: 1180 }, + { name: "Split View two thirds", width: 694, height: 1024 }, +] as const; + +for (const vp of MODAL_VIEWPORTS) { + test.describe(`${vp.name}: scroll after a modal`, () => { + test.beforeEach(async ({ page }) => { + await page.setViewportSize({ width: vp.width, height: vp.height }); + }); + + test("the page scrolls after the command palette dialog closes", async ({ + page, + browserName, + }) => { + await openDashboard(page); + await page.locator('[data-slot="command-palette-trigger"]:visible').tap(); + const input = page.locator('[data-slot="command-palette-input"]'); + await expect(input).toBeVisible(); + // The scroll lock makes the body `position: relative`; the shell + // behind the dialog keeps the window's box all the same. + await expectShellPinned(page); + await page.keyboard.press("Escape"); + await expect(input).toBeHidden(); + await expectPageScrollsAgain(page, browserName); + }); + + test("the page scrolls after a sheet closes", async ({ + page, + browserName, + }) => { + await openDashboard(page); + if (vp.width < 768) { + // The phone shell's More sheet: the draggable bottom sheet. + await page.locator('[data-testid="bottom-nav-more"]').tap(); + const sheet = page.locator('[data-testid="bottom-nav-more-sheet"]'); + await expect(sheet).toBeVisible(); + await expectShellPinned(page); + await sheet.locator('[data-slot="sheet-close-button"]').tap(); + await expect(sheet).toBeHidden(); + } else { + // The day, a sheet from the right below 1280 px. + await mockDay(page); + const day = new Date(Date.now() - 86_400_000) + .toISOString() + .slice(0, 10); + await page.goto(`/?day=${day}`, { waitUntil: "domcontentloaded" }); + const panel = page.locator('[data-slot="day-panel"]'); + await expect(panel).toBeVisible(); + await expectShellPinned(page); + await page.keyboard.press("Escape"); + await expect(panel).toBeHidden(); + } + await expectPageScrollsAgain(page, browserName); + }); + }); +} + +test("a vertical swipe that starts on a tab strip is the page's", async ({ + page, +}) => { + await page.setViewportSize({ width: 1180, height: 820 }); + await page.goto("/checkups", { waitUntil: "domcontentloaded" }); + const strip = page.locator('#main-content [data-slot="tabs-list"]').first(); + await expect(strip).toBeVisible(); + // `pan-x` here refused the vertical pan, so the page did not move. + const touchAction = await strip.evaluate( + (el) => getComputedStyle(el).touchAction, + ); + expect(touchAction).toBe("auto"); +}); diff --git a/playwright.config.ts b/playwright.config.ts index cd8a69347..4fbee7e5f 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -123,6 +123,8 @@ export default defineConfig({ "pwa-offline.spec.ts", // Runs only in the WebKit phone project. "bottom-sheet.spec.ts", + // Runs only in the two tablet projects. + "tablet-shell.spec.ts", ], use: { ...devices["Desktop Chrome"], @@ -240,6 +242,8 @@ export default defineConfig({ "pwa-offline.spec.ts", // Runs only in the WebKit phone project. "bottom-sheet.spec.ts", + // Runs only in the two tablet projects. + "tablet-shell.spec.ts", // The Apple Health import journey uploads an archive into the one // shared account and its refusal control asserts that the account's // Apple-Health row count did not move. A second project running the @@ -333,7 +337,28 @@ export default defineConfig({ colorScheme: "dark" as const, }, }, + // The app shell on an iPad (`tablet-shell.spec.ts`): the rail + // under a finger, `
` to its last line, scroll after a modal. + // In CI's WebKit job beside `webkit-iphone`. The spec sets each + // iPad size itself; the profile supplies touch and `isMobile`. + { + name: "webkit-tablet", + testMatch: ["tablet-shell.spec.ts"], + use: { + ...devices["iPad Pro 11 landscape"], + colorScheme: "dark" as const, + }, + }, ]), + // The same spec on an Android tablet, in the Chromium shards. + { + name: "chromium-tablet", + testMatch: ["tablet-shell.spec.ts"], + use: { + ...devices["Galaxy Tab S4 landscape"], + colorScheme: "dark" as const, + }, + }, ], // Spin up the production build for E2E. `pnpm build` is run From 0e7a18667772ebb6e4d5a8e3e7fef2e74f5b9673 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 18:24:30 +0200 Subject: [PATCH 05/10] test(ratchet): lower the shell and rail baselines sidebar-nav.tsx drops under 800 lines with its scroll region in a module of its own, and AuthShell and SidebarNav are shorter. --- src/__tests__/baselines/source-file-lines.json | 5 ++--- src/__tests__/baselines/source-function-lines.json | 6 +++--- src/__tests__/baselines/version-stamp-comments.json | 4 ++-- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/__tests__/baselines/source-file-lines.json b/src/__tests__/baselines/source-file-lines.json index 434124a03..fef8023c4 100644 --- a/src/__tests__/baselines/source-file-lines.json +++ b/src/__tests__/baselines/source-file-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", - "count": 91, - "total": 101342, + "count": 90, + "total": 100487, "entries": { "app/medications/page-client.tsx": 839, "components/charts/mood-chart.tsx": 1166, @@ -12,7 +12,6 @@ "components/insights/derived/vitals-dashboard.tsx": 1033, "components/insights/health-score-card.tsx": 1023, "components/insights/insights-tab-strip.tsx": 1086, - "components/layout/sidebar-nav.tsx": 855, "components/measurements/measurement-form.tsx": 815, "components/medications/detail/medication-detail-tabs.tsx": 1160, "components/medications/medication-table.tsx": 853, diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index 4dcbcf91c..e6ccf5f2a 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 355, - "total": 114773, + "total": 114755, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314, @@ -143,10 +143,10 @@ "components/labs/lab-list.tsx :: LabList": 308, "components/labs/ocr-review-dialog.tsx :: OcrReviewDialog": 359, "components/labs/ocr-row-editor.tsx :: OcrRowEditor": 393, - "components/layout/auth-shell.tsx :: AuthShell": 520, + "components/layout/auth-shell.tsx :: AuthShell": 518, "components/layout/bottom-nav.tsx :: BottomNav": 262, "components/layout/capture-picker.tsx :: CapturePicker": 235, - "components/layout/sidebar-nav.tsx :: SidebarNav": 372, + "components/layout/sidebar-nav.tsx :: SidebarNav": 356, "components/layout/top-bar.tsx :: TopBar": 212, "components/links/entity-link-picker.tsx :: EntityLinkPicker": 392, "components/measurement-reminders/vorsorge-dashboard-card.tsx :: VorsorgeDashboardCard": 215, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index 32b18fa49..3bc029651 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", "count": 2898, - "total": 9297, + "total": 9296, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -1098,7 +1098,7 @@ "components/layout/__tests__/shared-record-chrome.test.tsx": 1, "components/layout/__tests__/sidebar-nav.test.tsx": 3, "components/layout/account-switcher-menu.tsx": 1, - "components/layout/auth-shell.tsx": 15, + "components/layout/auth-shell.tsx": 14, "components/layout/bottom-nav.tsx": 8, "components/layout/capture-picker.tsx": 5, "components/layout/nav-model.ts": 17, From 8d10e5ab945e39c7d503676294991fe90aebea0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 19:27:52 +0200 Subject: [PATCH 06/10] feat(measurements): resolve each reading's Apple Health write-back A reading sent with a measurements:write token keeps its EXTERNAL source and now points at the token that carried it (measurements.ingest_token_id). Every stored row answers a server-resolved appleHealthMirror: true for WITHINGS, IMPORT and MANUAL, the token's choice for EXTERNAL, false otherwise. The plain list accepts appleHealthMirror=true so a client can page exactly those rows. Migration 0476 adds the columns and links existing EXTERNAL readings only where one measurement token provably sent them; 0477 builds the index concurrently. Backups carry the link and a restore keeps it only for a token the account still holds. A guard holds every EXTERNAL writer to setting the link. --- .../migration.sql | 80 ++++++++++ .../migration.sql | 8 + prisma/schema.prisma | 47 ++++-- .../external-source-token-link-guard.test.ts | 84 +++++++++++ src/app/api/measurements/[id]/route.ts | 140 ++++++++++-------- src/app/api/measurements/batch/route.ts | 2 + src/app/api/measurements/route.ts | 16 +- src/lib/export/measurement-bulk-insert.ts | 3 + src/lib/export/measurements-backup-schema.ts | 3 + src/lib/export/measurements-backup.ts | 2 + src/lib/export/measurements-restore.ts | 29 +++- .../__tests__/apple-health-mirror.test.ts | 84 +++++++++++ src/lib/measurements/apple-health-mirror.ts | 96 ++++++++++++ src/lib/measurements/batch-ingest/entries.ts | 5 + src/lib/measurements/batch-ingest/prepare.ts | 17 ++- .../batch-ingest/write-measurements.ts | 5 + .../measurements/create/create-readings.ts | 17 ++- .../measurements/create/create-sleep-stage.ts | 17 ++- src/lib/measurements/list/measurement-list.ts | 27 +++- src/lib/validations/measurement.ts | 21 +++ .../export-disaster-recovery.json | 10 ++ .../restore-disaster-recovery.json | 20 ++- .../backup-parity/restore-portable.json | 12 +- .../backup-parity/restore-skips.json | 3 +- .../backup-parity/restore-version-1.json | 16 +- .../backup-parity/seeded-account.json | 15 +- .../backup-token-link-restore.test.ts | 131 ++++++++++++++++ .../measurements.get-01-page.json.snap | 33 +++-- .../measurements.get-02-type-sorted.json.snap | 6 +- .../measurements.get-03-filtered.json.snap | 6 +- .../measurements.get-05-drill-down.json.snap | 3 +- .../measurements.post-01-single.json.snap | 7 +- ...surements.post-04-single-glucose.json.snap | 7 +- .../measurements.post-05-batch.json.snap | 21 ++- ...measurements.post-08-sleep-stage.json.snap | 1 + ...ements.post-09-sleep-stage-again.json.snap | 1 + ...ments.post-10-sleep-stage-mirror.json.snap | 1 + .../measurements.post-13-bridge.json.snap | 21 ++- .../measurements.post-14-delegated.json.snap | 7 +- .../token-link-backfill-migration.test.ts | 132 +++++++++++++++++ 40 files changed, 1014 insertions(+), 142 deletions(-) create mode 100644 prisma/migrations/0476_token_apple_health_mirror/migration.sql create mode 100644 prisma/migrations/0477_measurements_ingest_token_index/migration.sql create mode 100644 src/__tests__/external-source-token-link-guard.test.ts create mode 100644 src/lib/measurements/__tests__/apple-health-mirror.test.ts create mode 100644 src/lib/measurements/apple-health-mirror.ts create mode 100644 tests/integration/backup-token-link-restore.test.ts create mode 100644 tests/integration/token-link-backfill-migration.test.ts diff --git a/prisma/migrations/0476_token_apple_health_mirror/migration.sql b/prisma/migrations/0476_token_apple_health_mirror/migration.sql new file mode 100644 index 000000000..52ce04a08 --- /dev/null +++ b/prisma/migrations/0476_token_apple_health_mirror/migration.sql @@ -0,0 +1,80 @@ +-- Per-token Apple Health write-back (#1272). +-- +-- `api_tokens.mirror_to_apple_health` is the person's choice for a +-- `measurements:write` token; `mirror_changed_at` is when it last changed and +-- keys the `measurementMirror` domain of `/api/sync/changes`. Rows written by +-- such a token keep their honest `EXTERNAL` source and point at the token +-- through `measurements.ingest_token_id`; the server resolves the row's +-- `appleHealthMirror` from the token when it answers it. +-- +-- Each step is a `DO` block with a bounded lock wait, and each is safe to run +-- again, so the file is marked for the boot's lock-timeout retry: +-- +-- 1. the columns, and the foreign key added NOT VALID (no scan under the +-- table lock); +-- 2. the backfill: an existing EXTERNAL row is linked only where the +-- account has exactly one measurement token (revoked ones counted) and +-- the row is no older than that token, because only then is the token +-- provably the one that carried it. Every other existing row stays +-- unlinked and is not mirrored. The statement sets nothing but +-- `ingest_token_id`, so `updated_at` and `sync_version` are untouched; +-- 3. the foreign key validated, which scans without blocking writes. +-- +-- The index the key and the feed walk use is built CONCURRENTLY in 0477, on +-- its own, because a concurrent build cannot share a file. +-- +-- @retry-on-lock-timeout + +DO $migration$ +BEGIN + PERFORM set_config('lock_timeout', '5s', true); + ALTER TABLE "api_tokens" + ADD COLUMN IF NOT EXISTS "mirror_to_apple_health" BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS "mirror_changed_at" TIMESTAMP(3); + ALTER TABLE "measurements" + ADD COLUMN IF NOT EXISTS "ingest_token_id" TEXT; + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'measurements_ingest_token_id_fkey' + ) THEN + ALTER TABLE "measurements" + ADD CONSTRAINT "measurements_ingest_token_id_fkey" + FOREIGN KEY ("ingest_token_id") REFERENCES "api_tokens"("id") + ON DELETE SET NULL ON UPDATE CASCADE NOT VALID; + END IF; +END +$migration$; + +DO $migration$ +DECLARE + linked bigint; +BEGIN + PERFORM set_config('lock_timeout', '5s', true); + WITH sole_token AS ( + SELECT t."user_id", + (array_agg(t."id"))[1] AS token_id, + (array_agg(t."created_at"))[1] AS created_at + FROM "api_tokens" t + WHERE t."permissions" = ARRAY['measurements:write']::text[] + GROUP BY t."user_id" + HAVING count(*) = 1 + ) + UPDATE "measurements" m + SET "ingest_token_id" = s.token_id + FROM sole_token s + WHERE m."user_id" = s."user_id" + AND m."source" = 'EXTERNAL' + AND m."ingest_token_id" IS NULL + AND m."created_at" >= s.created_at; + GET DIAGNOSTICS linked = ROW_COUNT; + RAISE NOTICE '0476: linked % EXTERNAL readings to their sole measurement token', linked; +END +$migration$; + +DO $migration$ +BEGIN + PERFORM set_config('lock_timeout', '5s', true); + ALTER TABLE "measurements" + VALIDATE CONSTRAINT "measurements_ingest_token_id_fkey"; +END +$migration$; diff --git a/prisma/migrations/0477_measurements_ingest_token_index/migration.sql b/prisma/migrations/0477_measurements_ingest_token_index/migration.sql new file mode 100644 index 000000000..82c16c7b9 --- /dev/null +++ b/prisma/migrations/0477_measurements_ingest_token_index/migration.sql @@ -0,0 +1,8 @@ +-- The rows a measurement token carried (#1272): walked by the +-- `measurementMirror` domain of `/api/sync/changes` in id order, and looked up +-- by the foreign key's SET NULL. Built CONCURRENTLY so writes to +-- `measurements` carry on during the build; a concurrent build must be the +-- only statement in its file. Most rows have no token, so the index stays +-- small. +CREATE INDEX CONCURRENTLY IF NOT EXISTS "measurements_ingest_token_id_id_idx" + ON "measurements" ("ingest_token_id", "id"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e78805032..915db7acd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -2118,8 +2118,15 @@ model Measurement { deletedAt DateTime? @map("deleted_at") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") + /// The `measurements:write` token that carried this reading, set on every + /// `EXTERNAL` row (#1272). Read through to the token's `mirrorToAppleHealth` + /// when the row is answered; never on the wire itself. SET NULL if the token + /// row is ever removed (a revoke keeps it), and the row then stops being + /// mirrored. + ingestTokenId String? @map("ingest_token_id") user User @relation(fields: [userId], references: [id], onDelete: Cascade) + ingestToken ApiToken? @relation(fields: [ingestTokenId], references: [id], onDelete: SetNull) // v1.19.0 — optional back-relation to the full ECG waveform recording(s) // paired with this EVENT row. SET NULL on the FK side keeps the trace if // the EVENT row is re-keyed / removed. @@ -2175,6 +2182,9 @@ model Measurement { // changes nothing else, so those stay HOT; the table also carries // `fillfactor = 85` and a 5 % autovacuum threshold from the same migration. @@index([userId, updatedAt, id]) + // The rows a token carried, walked by the `measurementMirror` feed domain + // in id order and by the FK's SET NULL (migration 0477). + @@index([ingestTokenId, id]) // `measurements_legacy_step_consolidation_idx` is a partial index // over `type = 'ACTIVITY_STEPS' AND deleted_at IS NULL` — see // migration 0087. It accelerates the v1.5.6 legacy step-consolidation @@ -3936,26 +3946,37 @@ model TelegramPromptContext { // ─── API Tokens (for external ingest) ────────────────────── model ApiToken { - id String @id @default(cuid()) - userId String @map("user_id") - name String - tokenHash String @unique @map("token_hash") // SHA-256 of the token - permissions String[] @default(["medication:ingest"]) - lastUsedAt DateTime? @map("last_used_at") - expiresAt DateTime? @map("expires_at") - createdAt DateTime @default(now()) @map("created_at") - revoked Boolean @default(false) + id String @id @default(cuid()) + userId String @map("user_id") + name String + tokenHash String @unique @map("token_hash") // SHA-256 of the token + permissions String[] @default(["medication:ingest"]) + lastUsedAt DateTime? @map("last_used_at") + expiresAt DateTime? @map("expires_at") + createdAt DateTime @default(now()) @map("created_at") + revoked Boolean @default(false) // The MCP OAuth connection this access token belongs to, when it was minted // through the `/api/mcp/oauth` bridge. Null for manually-minted tokens and // native-client tokens. Lets a connection revoke kill every access row it // ever issued (H2), and lets the connector token list exclude the transient // 60-minute access rows so it does not flood (M2). - mcpConnectionId String? @map("mcp_connection_id") + mcpConnectionId String? @map("mcp_connection_id") + /// Whether the HealthLog app writes the readings this `measurements:write` + /// token carried into Apple Health (#1272). The person decides per token; + /// the server resolves it onto every row as `appleHealthMirror` + /// (`src/lib/measurements/apple-health-mirror.ts`). False for every other + /// kind of token, which `PATCH /api/tokens/{id}` refuses to set. + mirrorToAppleHealth Boolean @default(false) @map("mirror_to_apple_health") + /// When `mirrorToAppleHealth` last changed. The keyset of the + /// `measurementMirror` domain of `/api/sync/changes`, which re-delivers the + /// token's rows with their new value without touching the rows themselves. + mirrorChangedAt DateTime? @map("mirror_changed_at") - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - mcpConnection McpOAuthConnection? @relation(fields: [mcpConnectionId], references: [id], onDelete: SetNull) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + mcpConnection McpOAuthConnection? @relation(fields: [mcpConnectionId], references: [id], onDelete: SetNull) + ingestedMeasurements Measurement[] // v1.30.34 — step-up elevations bound to this token. Cascade-deleted with it. - stepUpElevations StepUpElevation[] + stepUpElevations StepUpElevation[] @@index([userId]) @@index([mcpConnectionId]) diff --git a/src/__tests__/external-source-token-link-guard.test.ts b/src/__tests__/external-source-token-link-guard.test.ts new file mode 100644 index 000000000..3c2eab454 --- /dev/null +++ b/src/__tests__/external-source-token-link-guard.test.ts @@ -0,0 +1,84 @@ +/** + * Every write that labels a measurement `EXTERNAL` also links it to the token + * that carried it (#1272). + * + * The label and the link answer one question together: whether the HealthLog + * app writes the reading into Apple Health is the choice its person made for + * that token (`apple-health-mirror.ts`). An EXTERNAL row written without the + * link resolves to false forever, whatever the token says, and nothing in the + * write path would notice. So the files that resolve `EXTERNAL_SOURCE` are an + * inventory: a measurement writer must set `ingestTokenId` at least as often + * as it assigns the label, and a file that starts using the constant fails + * here until it is classified. + * + * A tripwire, not a proof: it counts, it does not trace data flow. The + * behaviour is pinned by `tests/integration/apple-health-mirror.test.ts`. + */ +import { describe, expect, it } from "vitest"; +import { join } from "node:path"; + +import { + readSourceText, + stripComments, + walkSourceFiles, +} from "./helpers/source-files"; + +const SRC = join(process.cwd(), "src"); + +/** Files that write measurement rows labelled with the constant. */ +const MEASUREMENT_WRITERS = [ + "lib/measurements/batch-ingest/prepare.ts", + "lib/measurements/create/create-readings.ts", + "lib/measurements/create/create-sleep-stage.ts", +]; + +/** Files that use the constant for something other than a measurement write. */ +const NOT_MEASUREMENT_WRITES: Record = { + "app/api/tokens/measurements/route.ts": + "counts the readings that belong to no token (a read)", + "lib/validations/measurement.ts": "the source enum and the edit allowlist", + "lib/workouts/batch-ingest/entries.ts": + "labels workout rows, which carry no token link", +}; + +function usesOfConstant(rel: string): { + assignments: number; + links: number; +} { + const code = stripComments(readSourceText(join(SRC, rel))); + const assignments = (code.match(/\?\s*EXTERNAL_SOURCE\b/g) ?? []).length; + // A value written under the key, or the shorthand. Not a type member + // (`ingestTokenId: string | null`) and not a read (`writer.ingestTokenId`), + // which write nothing. + const links = ( + code.match(/(? { + const files = walkSourceFiles(SRC, { floor: 3000 }) + .filter((p) => !p.startsWith("generated/")) + .filter((p) => !p.includes("__tests__")) + .filter((p) => !/\.test\.tsx?$/.test(p)) + .filter((p) => p !== "lib/measurements/external-source.ts") + .filter((p) => + /\bEXTERNAL_SOURCE\b/.test(stripComments(readSourceText(join(SRC, p)))), + ); + + it("knows every file that uses the constant", () => { + expect(files.length).toBeGreaterThan(0); + expect(files).toEqual( + [...MEASUREMENT_WRITERS, ...Object.keys(NOT_MEASUREMENT_WRITES)].sort(), + ); + }); + + it.each(MEASUREMENT_WRITERS)( + "%s links the token wherever it assigns the label", + (rel) => { + const { assignments, links } = usesOfConstant(rel); + expect(assignments).toBeGreaterThan(0); + expect(links).toBeGreaterThanOrEqual(assignments); + }, + ); +}); diff --git a/src/app/api/measurements/[id]/route.ts b/src/app/api/measurements/[id]/route.ts index e0fd951f8..84e18c624 100644 --- a/src/app/api/measurements/[id]/route.ts +++ b/src/app/api/measurements/[id]/route.ts @@ -17,9 +17,13 @@ import { validateMeasurementRange, } from "@/lib/validations/measurement"; import { encryptNote, shapeMeasurementNotes } from "@/lib/crypto/note-cipher"; +import { + APPLE_HEALTH_MIRROR_INCLUDE, + withAppleHealthMirror, +} from "@/lib/measurements/apple-health-mirror"; import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation"; -import { Prisma } from "@/generated/prisma/client"; +import { Prisma, type MeasurementType } from "@/generated/prisma/client"; import { NextRequest } from "next/server"; import { z } from "zod"; @@ -37,6 +41,7 @@ export const GET = apiHandler( // is not part of a unique index. const measurement = await prisma.measurement.findFirst({ where: { id, deletedAt: null }, + include: APPLE_HEALTH_MIRROR_INCLUDE, }); if (!measurement || measurement.userId !== user.id) { @@ -51,7 +56,9 @@ export const GET = apiHandler( }, }); - return apiSuccess(shapeMeasurementNotes(measurement)); + return apiSuccess( + withAppleHealthMirror(shapeMeasurementNotes(measurement)), + ); }, ); @@ -113,62 +120,8 @@ export const PUT = apiHandler( // implausible edited value flowed unchecked into rollups, the health // score, the BP gates and the Coach snapshot. if (data.value !== undefined && data.value !== existing.value) { - // Server-owned rows first: a value attributed to a connector / import / - // computed engine is the provider's reading — editing the number would - // forge a source-attributed row the server never received. Timestamp - // and note edits stay allowed — annotating a Withings reading is - // legitimate. - // - // v1.38.x — gated on `USER_CORRECTABLE_MEASUREMENT_SOURCES` rather than - // the write-side `WRITABLE_MEASUREMENT_SOURCES` the two once shared, - // because `EXTERNAL` separates the questions. A client may not NAME that - // source (it is resolved from the ingest credential, and the write - // allowlist is published as `ingest.writeAllowlist`), but the hardware - // behind the token is the user's own scale, so the rationale above — - // "the value is the provider's" — does not reach it. Locking those rows - // would also break the correctability the settings card and the Home - // Assistant guide promise. This is the only site that reads the wider - // set; re-merging the two constants silently value-locks bridge rows. - if ( - !(USER_CORRECTABLE_MEASUREMENT_SOURCES as readonly string[]).includes( - existing.source, - ) - ) { - annotate({ - action: { name: "measurements.update.server-owned-source" }, - meta: { measurement_id: id, source: existing.source }, - }); - return apiError( - "Values from a connected source cannot be edited", - 409, - { errorCode: "measurement.update.server_owned_source" }, - ); - } - - // Range check against the row's OWN type (the edit body carries no - // type). Returned through the standard multi-issue 422 envelope so the - // edit sheet renders it like any other field error. - const rangeCheck = z - .object({ - value: z.number().superRefine((value, ctx) => { - const rangeError = validateMeasurementRange(existing.type, value); - if (rangeError) { - ctx.addIssue({ code: "custom", message: rangeError }); - } - }), - }) - .safeParse({ value: data.value }); - if (!rangeCheck.success) { - annotate({ - action: { name: "measurements.update.validation-failed" }, - meta: { - issue_count: rangeCheck.error.issues.length, - measurement_id: id, - reason: "value_out_of_range", - }, - }); - return returnAllZodIssues(rangeCheck.error, 422); - } + const refused = refuseValueEdit(existing, data.value, id); + if (refused) return refused; } let measurement; @@ -185,6 +138,7 @@ export const PUT = apiHandler( notesEncrypted: encryptNote(data.notes), }), }, + include: APPLE_HEALTH_MIRROR_INCLUDE, }); } catch (err) { // v1.4.28 — re-pointing `measuredAt` onto an existing @@ -252,7 +206,9 @@ export const PUT = apiHandler( { source: "manual" }, ); - return apiSuccess(shapeMeasurementNotes(measurement)); + return apiSuccess( + withAppleHealthMirror(shapeMeasurementNotes(measurement)), + ); }, ); @@ -320,3 +276,69 @@ export const DELETE = apiHandler( return apiSuccess({ deleted: true }); }, ); + +/** + * Whether a changed value may be written over `existing`, as the refusal to + * answer when it may not; null when it may. + */ +function refuseValueEdit( + existing: { source: string; type: MeasurementType }, + value: number, + id: string, +): Response | null { + // Server-owned rows first: a value attributed to a connector / import / + // computed engine is the provider's reading — editing the number would + // forge a source-attributed row the server never received. Timestamp + // and note edits stay allowed — annotating a Withings reading is + // legitimate. + // + // v1.38.x — gated on `USER_CORRECTABLE_MEASUREMENT_SOURCES` rather than + // the write-side `WRITABLE_MEASUREMENT_SOURCES` the two once shared, + // because `EXTERNAL` separates the questions. A client may not NAME that + // source (it is resolved from the ingest credential, and the write + // allowlist is published as `ingest.writeAllowlist`), but the hardware + // behind the token is the user's own scale, so the rationale above — + // "the value is the provider's" — does not reach it. Locking those rows + // would also break the correctability the settings card and the Home + // Assistant guide promise. This is the only site that reads the wider + // set; re-merging the two constants silently value-locks bridge rows. + if ( + !(USER_CORRECTABLE_MEASUREMENT_SOURCES as readonly string[]).includes( + existing.source, + ) + ) { + annotate({ + action: { name: "measurements.update.server-owned-source" }, + meta: { measurement_id: id, source: existing.source }, + }); + return apiError("Values from a connected source cannot be edited", 409, { + errorCode: "measurement.update.server_owned_source", + }); + } + + // Range check against the row's OWN type (the edit body carries no + // type). Returned through the standard multi-issue 422 envelope so the + // edit sheet renders it like any other field error. + const rangeCheck = z + .object({ + value: z.number().superRefine((value, ctx) => { + const rangeError = validateMeasurementRange(existing.type, value); + if (rangeError) { + ctx.addIssue({ code: "custom", message: rangeError }); + } + }), + }) + .safeParse({ value: value }); + if (!rangeCheck.success) { + annotate({ + action: { name: "measurements.update.validation-failed" }, + meta: { + issue_count: rangeCheck.error.issues.length, + measurement_id: id, + reason: "value_out_of_range", + }, + }); + return returnAllZodIssues(rangeCheck.error, 422); + } + return null; +} diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index 2f3b785e0..921774089 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -261,6 +261,8 @@ async function postBatch(request: NextRequest): Promise { const result = await ingestMeasurementBatch({ user: { id: user.id, timezone: user.timezone }, scoped, + // On the Bearer path the token's id rides `session.id`. + ingestTokenId: scoped ? auth.session.id : null, entries, syncTrigger: parsed.data.syncTrigger, ipAddress: getClientIp(request), diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index 1a142980f..e138554cb 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -21,6 +21,10 @@ import { } from "@/lib/validations/measurement"; import { withIdempotency } from "@/lib/idempotency"; import { shapeMeasurementNotes } from "@/lib/crypto/note-cipher"; +import { + type MeasurementWithMirrorInput, + withAppleHealthMirror, +} from "@/lib/measurements/apple-health-mirror"; import { measurementListResponse } from "@/lib/measurements/list/measurement-list"; import { createReading, @@ -154,6 +158,8 @@ async function postMeasurement(request: NextRequest) { const writer: ReadingWriter = { user: { id: user.id, timezone: user.timezone }, scoped, + // On the Bearer path the token's id rides `session.id`. + ingestTokenId: scoped ? auth.session.id : null, ipAddress: getClientIp(request), }; @@ -202,7 +208,7 @@ async function postMeasurement(request: NextRequest) { if ("duplicate" in created) { return apiError(DUPLICATE_MESSAGE, 409, DUPLICATE_META); } - return apiSuccess(created.rows.map(shapeMeasurementNotes), 201); + return apiSuccess(created.rows.map(answerRow), 201); } // Single mode (existing behavior) @@ -237,7 +243,12 @@ async function postMeasurement(request: NextRequest) { if ("duplicate" in created) { return apiError(DUPLICATE_MESSAGE, 409, DUPLICATE_META); } - return apiSuccess(shapeMeasurementNotes(created.row), 201); + return apiSuccess(answerRow(created.row), 201); +} + +/** A written row as it is answered: the note readable, the write-back resolved. */ +function answerRow(row: MeasurementWithMirrorInput) { + return withAppleHealthMirror(shapeMeasurementNotes(row)); } /** @@ -262,6 +273,7 @@ async function postNamedSleepStage( { userId: writer.user.id, scoped: writer.scoped, + ingestTokenId: writer.ingestTokenId, ipAddress: writer.ipAddress, }, parsed.data, diff --git a/src/lib/export/measurement-bulk-insert.ts b/src/lib/export/measurement-bulk-insert.ts index adbb848ae..440d0bac8 100644 --- a/src/lib/export/measurement-bulk-insert.ts +++ b/src/lib/export/measurement-bulk-insert.ts @@ -66,6 +66,8 @@ export interface MeasurementInsertRow { sleepStage: string | null; rhythmClassification: string | null; deviceType: string | null; + /** The token an EXTERNAL row came from (#1272); null when absent. */ + ingestTokenId?: string | null; syncVersion: number; deletedAt: Date | null; createdAt?: Date; @@ -98,6 +100,7 @@ const COLUMNS: { [K in keyof MeasurementInsertRow]-?: [string, Kind] } = { sleepStage: ["sleep_stage", "enum:sleep_stage"], rhythmClassification: ["rhythm_classification", "enum:rhythm_classification"], deviceType: ["device_type", "text"], + ingestTokenId: ["ingest_token_id", "text"], syncVersion: ["sync_version", "int4"], deletedAt: ["deleted_at", "timestamp"], createdAt: ["created_at", "timestamp"], diff --git a/src/lib/export/measurements-backup-schema.ts b/src/lib/export/measurements-backup-schema.ts index 1a5335b0d..3bddc120b 100644 --- a/src/lib/export/measurements-backup-schema.ts +++ b/src/lib/export/measurements-backup-schema.ts @@ -41,6 +41,9 @@ export const measurementSchema = z sleepStage: z.enum(SleepStage).nullable().optional(), rhythmClassification: z.enum(RhythmClassification).nullable().optional(), deviceType: z.string().nullable().optional(), + // The token that carried an EXTERNAL reading (#1272). Kept on restore + // only when the restoring account still holds that token. + ingestTokenId: z.string().max(64).nullable().optional(), syncVersion: z.number().int().optional(), deletedAt: isoDateTime.nullable().optional(), createdAt: isoDateTime.optional(), diff --git a/src/lib/export/measurements-backup.ts b/src/lib/export/measurements-backup.ts index 63b17597d..2dca35e37 100644 --- a/src/lib/export/measurements-backup.ts +++ b/src/lib/export/measurements-backup.ts @@ -42,6 +42,7 @@ const MEASUREMENT_BACKUP_SELECT = { sleepStage: true, rhythmClassification: true, deviceType: true, + ingestTokenId: true, syncVersion: true, deletedAt: true, createdAt: true, @@ -86,6 +87,7 @@ function serialiseMeasurement( sleepStage: measurement.sleepStage, rhythmClassification: measurement.rhythmClassification, deviceType: measurement.deviceType, + ingestTokenId: measurement.ingestTokenId, syncVersion: measurement.syncVersion, deletedAt: measurement.deletedAt?.toISOString() ?? null, createdAt: measurement.createdAt.toISOString(), diff --git a/src/lib/export/measurements-restore.ts b/src/lib/export/measurements-restore.ts index a6947d4be..bf08ef31c 100644 --- a/src/lib/export/measurements-restore.ts +++ b/src/lib/export/measurements-restore.ts @@ -156,7 +156,10 @@ export async function clearMeasurementRollups( await ctx.tx.measurementRollup.deleteMany({ where: { userId: ctx.ownerId } }); } -function toRestoredMeasurementData(measurement: BackupMeasurement) { +function toRestoredMeasurementData( + measurement: BackupMeasurement, + ownTokenIds: ReadonlySet, +) { return { type: isLegacyRmssdRow(measurement) ? ("HRV_RMSSD" as const) @@ -204,6 +207,13 @@ function toRestoredMeasurementData(measurement: BackupMeasurement) { sleepStage: (measurement.sleepStage ?? null) as never, rhythmClassification: (measurement.rhythmClassification ?? null) as never, deviceType: measurement.deviceType ?? null, + // The token link (#1272) survives only onto the account that still holds + // the token: a backup restored elsewhere, or after the token was deleted, + // would point at a row that is not there or not this account's. + ingestTokenId: + measurement.ingestTokenId && ownTokenIds.has(measurement.ingestTokenId) + ? measurement.ingestTokenId + : null, syncVersion: measurement.syncVersion ?? 1, deletedAt: measurement.deletedAt ? new Date(measurement.deletedAt) : null, ...(measurement.createdAt @@ -343,8 +353,9 @@ async function reconcileWithoutId( tx: Prisma.TransactionClient, ownerId: string, measurement: BackupMeasurement, + ownTokenIds: ReadonlySet, ): Promise { - const restoredData = toRestoredMeasurementData(measurement); + const restoredData = toRestoredMeasurementData(measurement, ownTokenIds); const existing = await tx.measurement.findFirst({ where: { userId: ownerId, @@ -378,6 +389,16 @@ export async function restoreMeasurements(ctx: RestoreContext): Promise { const measurementBatchSize = 1_000; const isExpiredTombstone = await expiredTombstoneFilter(ctx); const { isShadowedLegacyRmssd } = ctx.measurementPrepass; + // Tokens are not part of a backup; the ones the account holds now decide + // which token links come back. + const ownTokenIds = new Set( + ( + await tx.apiToken.findMany({ + where: { userId: ownerId }, + select: { id: true }, + }) + ).map((token) => token.id), + ); ctx.reportMeasurements(0); await streamed.forEachMeasurementBatch( measurementBatchSize, @@ -395,7 +416,7 @@ export async function restoreMeasurements(ctx: RestoreContext): Promise { { id: measurement.id, userId: ownerId, - ...toRestoredMeasurementData(measurement), + ...toRestoredMeasurementData(measurement, ownTokenIds), }, ] : [], @@ -412,7 +433,7 @@ export async function restoreMeasurements(ctx: RestoreContext): Promise { } for (const measurement of batch) { if (measurement.id) continue; - await reconcileWithoutId(tx, ownerId, measurement); + await reconcileWithoutId(tx, ownerId, measurement, ownTokenIds); } }, ); diff --git a/src/lib/measurements/__tests__/apple-health-mirror.test.ts b/src/lib/measurements/__tests__/apple-health-mirror.test.ts new file mode 100644 index 000000000..0e11e099a --- /dev/null +++ b/src/lib/measurements/__tests__/apple-health-mirror.test.ts @@ -0,0 +1,84 @@ +/** + * The Apple Health write-back rule (#1272), over every source the enum has. + * + * WITHINGS, IMPORT and MANUAL are true whatever the row's token says, the + * app's own rule as it stood; EXTERNAL follows its token; everything else is + * false. The Prisma filter the list's `appleHealthMirror=true` uses is the + * same rule written as a `where`, checked here against the resolver for each + * source and token state (the database agrees in + * `tests/integration/apple-health-mirror.test.ts`). + */ +import { describe, expect, it } from "vitest"; + +import { MEASUREMENT_SOURCES } from "@/lib/measurements/sources"; +import { + APPLE_HEALTH_MIRROR_WHERE, + resolveAppleHealthMirror, + withAppleHealthMirror, +} from "../apple-health-mirror"; + +const TOKEN_STATES = [ + undefined, + null, + { mirrorToAppleHealth: false }, + { mirrorToAppleHealth: true }, +] as const; + +/** The filter's two arms, evaluated against one row. */ +function matchesWhere(row: { + source: string; + ingestToken?: { mirrorToAppleHealth: boolean } | null; +}): boolean { + const [always, external] = APPLE_HEALTH_MIRROR_WHERE.OR; + return ( + (always.source.in as readonly string[]).includes(row.source) || + (row.source === external.source && + row.ingestToken?.mirrorToAppleHealth === + external.ingestToken.mirrorToAppleHealth) + ); +} + +describe("resolveAppleHealthMirror", () => { + it("is true for WITHINGS, IMPORT and MANUAL and an opted-in EXTERNAL", () => { + const truthy = MEASUREMENT_SOURCES.flatMap((source) => + TOKEN_STATES.filter((ingestToken) => + resolveAppleHealthMirror({ source, ingestToken }), + ).map((ingestToken) => `${source}:${JSON.stringify(ingestToken)}`), + ); + expect(truthy.sort()).toEqual( + [ + ...["IMPORT", "MANUAL", "WITHINGS"].flatMap((source) => + TOKEN_STATES.map((s) => `${source}:${JSON.stringify(s)}`), + ), + 'EXTERNAL:{"mirrorToAppleHealth":true}', + ].sort(), + ); + }); + + it("agrees with the list filter for every source and token state", () => { + for (const source of MEASUREMENT_SOURCES) { + for (const ingestToken of TOKEN_STATES) { + expect( + matchesWhere({ source, ingestToken }), + `${source} ${JSON.stringify(ingestToken)}`, + ).toBe(resolveAppleHealthMirror({ source, ingestToken })); + } + } + }); +}); + +describe("withAppleHealthMirror", () => { + it("adds the decision and drops the token and its id", () => { + const row = withAppleHealthMirror({ + id: "m1", + source: "EXTERNAL", + ingestTokenId: "tok-1", + ingestToken: { mirrorToAppleHealth: true }, + }); + expect(row).toEqual({ + id: "m1", + source: "EXTERNAL", + appleHealthMirror: true, + }); + }); +}); diff --git a/src/lib/measurements/apple-health-mirror.ts b/src/lib/measurements/apple-health-mirror.ts new file mode 100644 index 000000000..0dc3e801b --- /dev/null +++ b/src/lib/measurements/apple-health-mirror.ts @@ -0,0 +1,96 @@ +/** + * Whether the HealthLog app writes a server measurement into Apple Health + * (#1272). Resolved here, once, and published on every measurement row as + * `appleHealthMirror`; the app mirrors exactly the rows that say true instead + * of deriving the decision from `source`. + * + * The rule: + * + * - WITHINGS, IMPORT and MANUAL: true. This is the app's own rule as it + * stands (`MeasurementSource.isServerMirrorEligible`), so publishing the + * field changes nothing for an existing row. MANUAL is deliberate: a + * reading typed on the web or on another device is stored as MANUAL and + * never reached Apple Health otherwise, and a manual row the app already + * wrote at create time is recognised by the app's own dedup (the sample + * carries the server id), not by its source. + * - EXTERNAL: what the person chose for the `measurements:write` token that + * carried the reading (`ApiToken.mirrorToAppleHealth`). The source stays + * EXTERNAL either way; a token never claims another provenance. A row with + * no token (written before the link existed and not attributable to one) + * is not mirrored. + * - Everything else (APPLE_HEALTH, the provider syncs, COMPUTED, TELEGRAM, + * MCP, …): false. Apple Health rows came from Apple Health, and the + * provider and server-written rows are never authored into it. + */ +import type { MeasurementSource, Prisma } from "@/generated/prisma/client"; + +/** Sources the app mirrors whatever the token says. */ +const ALWAYS_MIRRORED: ReadonlySet = new Set([ + "WITHINGS", + "IMPORT", + "MANUAL", +]); + +/** What a row read needs to resolve the decision. */ +export const APPLE_HEALTH_MIRROR_SELECT = { + source: true, + ingestToken: { select: { mirrorToAppleHealth: true } }, +} as const satisfies Prisma.MeasurementSelect; + +/** The same, for a read or write that returns the whole row. */ +export const APPLE_HEALTH_MIRROR_INCLUDE = { + ingestToken: APPLE_HEALTH_MIRROR_SELECT.ingestToken, +} as const satisfies Prisma.MeasurementInclude; + +/** A whole row read or written with `APPLE_HEALTH_MIRROR_INCLUDE`. */ +export type MeasurementWithMirrorInput = Prisma.MeasurementGetPayload<{ + include: typeof APPLE_HEALTH_MIRROR_INCLUDE; +}>; + +export function resolveAppleHealthMirror(row: { + source: MeasurementSource | string; + ingestToken?: { mirrorToAppleHealth: boolean } | null; +}): boolean { + if (ALWAYS_MIRRORED.has(row.source as MeasurementSource)) return true; + if (row.source === "EXTERNAL") { + return row.ingestToken?.mirrorToAppleHealth === true; + } + return false; +} + +/** + * The rows that resolve true, as a Prisma filter: the list's + * `appleHealthMirror=true` filter, which the app's historical backfill pages + * instead of one fetch per source. Kept beside the rule so the two cannot + * disagree (pinned by `apple-health-mirror.test.ts`). + */ +export const APPLE_HEALTH_MIRROR_WHERE = { + OR: [ + { source: { in: [...ALWAYS_MIRRORED] } }, + { source: "EXTERNAL", ingestToken: { mirrorToAppleHealth: true } }, + ], +} as const satisfies Prisma.MeasurementWhereInput; + +/** + * A row read with `APPLE_HEALTH_MIRROR_SELECT` (or `_INCLUDE`), answered: the + * resolved `appleHealthMirror` added, the token relation and the token id + * dropped, so neither ever reaches the wire. + */ +export function withAppleHealthMirror< + T extends { + source: MeasurementSource | string; + ingestToken?: { mirrorToAppleHealth: boolean } | null; + ingestTokenId?: string | null; + }, +>( + row: T, +): Omit & { appleHealthMirror: boolean } { + const { ingestToken, ingestTokenId: _ingestTokenId, ...rest } = row; + return { + ...rest, + appleHealthMirror: resolveAppleHealthMirror({ + source: row.source, + ingestToken, + }), + }; +} diff --git a/src/lib/measurements/batch-ingest/entries.ts b/src/lib/measurements/batch-ingest/entries.ts index f247682e2..5b07c4bc2 100644 --- a/src/lib/measurements/batch-ingest/entries.ts +++ b/src/lib/measurements/batch-ingest/entries.ts @@ -34,6 +34,11 @@ export interface BatchIngestInput { user: { id: string; timezone: string | null }; /** A narrow single-purpose Bearer (a bridge), not the native client. */ scoped: boolean; + /** + * The bridge token that carried the batch, set exactly when `scoped`: each + * row it writes points at it (`Measurement.ingestTokenId`, #1272). + */ + ingestTokenId: string | null; entries: BatchIngestEntry[]; syncTrigger: HealthKitRequestSyncTrigger | undefined; ipAddress: string | null; diff --git a/src/lib/measurements/batch-ingest/prepare.ts b/src/lib/measurements/batch-ingest/prepare.ts index fc5eb9d2b..d9f7aa4a3 100644 --- a/src/lib/measurements/batch-ingest/prepare.ts +++ b/src/lib/measurements/batch-ingest/prepare.ts @@ -44,9 +44,13 @@ export interface PreparedBatch { } export async function prepareBatch( - input: Pick, + input: Pick< + BatchIngestInput, + "user" | "scoped" | "ingestTokenId" | "entries" + >, ): Promise { const { user, scoped, entries } = input; + const ingestTokenId = scoped ? input.ingestTokenId : null; const batch: PreparedBatch = { prepared: [], results: new Array(entries.length), @@ -94,7 +98,13 @@ export async function prepareBatch( continue; } - const prepared = prepareMeasurementEntry(index, entry, user.id, scoped); + const prepared = prepareMeasurementEntry( + index, + entry, + user.id, + scoped, + ingestTokenId, + ); if ("row" in prepared) batch.prepared.push(prepared); else batch.results[index] = prepared; } @@ -183,6 +193,7 @@ function prepareMeasurementEntry( entry: BatchIngestEntry, userId: string, scoped: boolean, + ingestTokenId: string | null, ): PreparedEntry | EntryResult { const mapped = mapAppleHealthEntry({ hkIdentifier: entry.hkIdentifier, @@ -293,6 +304,8 @@ function prepareMeasurementEntry( // `(userId, type, EXTERNAL, externalId)`, which is the isolated // namespace this source exists to give. source: scoped ? EXTERNAL_SOURCE : (entry.source ?? "APPLE_HEALTH"), + // The token an EXTERNAL row came from (#1272); null for the phone. + ingestTokenId, measuredAt: mapped.takenAt, externalId: entry.externalId, externalSourceVersion: entry.externalSourceVersion ?? null, diff --git a/src/lib/measurements/batch-ingest/write-measurements.ts b/src/lib/measurements/batch-ingest/write-measurements.ts index 225e78587..96bd57060 100644 --- a/src/lib/measurements/batch-ingest/write-measurements.ts +++ b/src/lib/measurements/batch-ingest/write-measurements.ts @@ -92,6 +92,11 @@ function desiredFor(userId: string, p: PreparedEntry) { .rhythmClassification as Prisma.MeasurementUncheckedCreateInput["rhythmClassification"], deviceType: p.row.deviceType as string | null, aggregationProvenance: statsRow ? ("HEALTHKIT_STATISTICS" as const) : null, + // Only a bridge row names its token (#1272); a phone row leaves the + // column as it is. + ...(p.row.ingestTokenId + ? { ingestTokenId: p.row.ingestTokenId as string } + : {}), }; } diff --git a/src/lib/measurements/create/create-readings.ts b/src/lib/measurements/create/create-readings.ts index c89a2fc5f..900f7cd96 100644 --- a/src/lib/measurements/create/create-readings.ts +++ b/src/lib/measurements/create/create-readings.ts @@ -10,6 +10,10 @@ import type { z } from "zod/v4"; import { prisma } from "@/lib/db"; import { EXTERNAL_SOURCE } from "@/lib/measurements/external-source"; +import { + APPLE_HEALTH_MIRROR_INCLUDE, + type MeasurementWithMirrorInput, +} from "@/lib/measurements/apple-health-mirror"; import { annotate } from "@/lib/logging/context"; import { fireAndForget } from "@/lib/logging/fire-and-forget"; import { auditLog } from "@/lib/auth/audit"; @@ -39,6 +43,11 @@ export interface ReadingWriter { user: { id: string; timezone: string | null }; /** A narrow bridge credential: its rows are `EXTERNAL`. */ scoped: boolean; + /** + * The bridge token, set exactly when `scoped`: each row it writes points + * at it (`Measurement.ingestTokenId`, #1272). + */ + ingestTokenId: string | null; ipAddress: string | null; } @@ -52,7 +61,7 @@ export const DUPLICATE_READING = { duplicate: true } as const; export async function createReadings( writer: ReadingWriter, input: z.output, -): Promise<{ rows: Measurement[] } | typeof DUPLICATE_READING> { +): Promise<{ rows: MeasurementWithMirrorInput[] } | typeof DUPLICATE_READING> { const { user, scoped } = writer; // The caller has to be told which condition the batch hit. Untouched, a // duplicate escaped as an unhandled error and the batch answered 500, @@ -77,6 +86,7 @@ export async function createReadings( source: (scoped ? EXTERNAL_SOURCE : (m.source ?? "MANUAL")) as MeasurementSource, + ingestTokenId: scoped ? writer.ingestTokenId : null, measuredAt: m.measuredAt, // The note is sealed at rest; the legacy plaintext column is // written null for new rows. @@ -88,6 +98,7 @@ export async function createReadings( // `deviceType` instead of silently dropping it. deviceType: m.deviceType ?? null, }, + include: APPLE_HEALTH_MIRROR_INCLUDE, }), ), ); @@ -123,7 +134,7 @@ export async function createReadings( export async function createReading( writer: ReadingWriter, input: z.output, -): Promise<{ row: Measurement } | typeof DUPLICATE_READING> { +): Promise<{ row: MeasurementWithMirrorInput } | typeof DUPLICATE_READING> { const { user, scoped } = writer; const { type, @@ -150,6 +161,7 @@ export async function createReading( source: (scoped ? EXTERNAL_SOURCE : (source ?? "MANUAL")) as MeasurementSource, + ingestTokenId: scoped ? writer.ingestTokenId : null, measuredAt, // The note is sealed at rest; the legacy plaintext column is nulled. notes: null, @@ -160,6 +172,7 @@ export async function createReading( // silently dropping it. deviceType: deviceType ?? null, }, + include: APPLE_HEALTH_MIRROR_INCLUDE, }); } catch (err) { // `isP2002` rather than `instanceof Prisma.PrismaClientKnownRequestError`. diff --git a/src/lib/measurements/create/create-sleep-stage.ts b/src/lib/measurements/create/create-sleep-stage.ts index 79a7da6f8..b363a1f37 100644 --- a/src/lib/measurements/create/create-sleep-stage.ts +++ b/src/lib/measurements/create/create-sleep-stage.ts @@ -23,6 +23,10 @@ import { auditLog } from "@/lib/auth/audit"; import { apiError, apiSuccess } from "@/lib/api-response"; import { getUnitForType } from "@/lib/validations/measurement"; import { shapeMeasurementNotes } from "@/lib/crypto/note-cipher"; +import { + APPLE_HEALTH_MIRROR_INCLUDE, + withAppleHealthMirror, +} from "@/lib/measurements/apple-health-mirror"; import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit"; import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger"; @@ -51,6 +55,8 @@ export async function createNamedSleepStage( args: { userId: string; scoped: boolean; + /** The bridge token, set exactly when `scoped` (#1272). */ + ingestTokenId: string | null; ipAddress: string | null; }, entry: z.output, @@ -113,6 +119,10 @@ export async function createNamedSleepStage( externalSourceVersion: null, sleepStage: segment.stage, deviceType: entry.deviceType ?? null, + // The token an EXTERNAL row came from (#1272). + ...(scoped && args.ingestTokenId + ? { ingestTokenId: args.ingestTokenId } + : {}), }, { exactExternalMatch: "duplicate" }, ), @@ -190,9 +200,12 @@ async function namedSleepStageResponse( cross_source_merge: meta.crossSource, }, }); - const row = await prisma.measurement.findUniqueOrThrow({ where: { id } }); + const row = await prisma.measurement.findUniqueOrThrow({ + where: { id }, + include: APPLE_HEALTH_MIRROR_INCLUDE, + }); return apiSuccess( - { ...shapeMeasurementNotes(row), status }, + { ...withAppleHealthMirror(shapeMeasurementNotes(row)), status }, status === "inserted" ? 201 : 200, ); } diff --git a/src/lib/measurements/list/measurement-list.ts b/src/lib/measurements/list/measurement-list.ts index 2aea645b1..56e793670 100644 --- a/src/lib/measurements/list/measurement-list.ts +++ b/src/lib/measurements/list/measurement-list.ts @@ -20,6 +20,11 @@ import { import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping"; import { localDayWindow } from "@/lib/tz/local-day"; import { shapeMeasurementNotes } from "@/lib/crypto/note-cipher"; +import { + APPLE_HEALTH_MIRROR_SELECT, + APPLE_HEALTH_MIRROR_WHERE, + withAppleHealthMirror, +} from "@/lib/measurements/apple-health-mirror"; import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; import { readDailySeries, @@ -59,8 +64,18 @@ const MEASUREMENT_WIRE_SELECT = { deletedAt: true, createdAt: true, updatedAt: true, + // Resolved onto `appleHealthMirror` and dropped by `withAppleHealthMirror`. + ingestToken: APPLE_HEALTH_MIRROR_SELECT.ingestToken, } as const satisfies Prisma.MeasurementSelect; +/** One row as it is answered: the note readable, the write-back resolved. */ +function wireRow< + T extends Parameters[0] & + Parameters[0], +>(row: T) { + return withAppleHealthMirror(shapeMeasurementNotes(row)); +} + type ListQuery = z.output; type ListUser = { id: string; timezone: string | null }; @@ -202,7 +217,7 @@ async function drillDownResponse( return apiSuccess({ // The same row shape as the plain page: the note decrypted onto `notes`, // the ciphertext never on the wire. - measurements: samples.map(shapeMeasurementNotes), + measurements: samples.map(wireRow), meta: { total: samples.length, limit, offset: 0, dayKey }, }); } @@ -281,7 +296,7 @@ async function plainPageResponse( query: ListQuery, ): Promise { const { type, from, to, limit, offset, sortBy, sortDir } = query; - const { sourceEq, valueMin, valueMax } = query; + const { sourceEq, valueMin, valueMax, appleHealthMirror } = query; const where = { userId, // Soft-deleted readings are invisible to every list / count / aggregate @@ -294,6 +309,9 @@ async function plainPageResponse( // opt-in (which selects the rollup read tier); `sourceEq` narrows the // plain list to one ingest source. ...(sourceEq && { source: sourceEq }), + // The rows the app mirrors into Apple Health, by the same rule the + // field is resolved with (`apple-health-mirror.ts`). + ...(appleHealthMirror === "true" && { AND: [APPLE_HEALTH_MIRROR_WHERE] }), // Value-range filter. Open-ended on either side; the validator has // already rejected an inverted `valueMin > valueMax`. ...(valueMin != null || valueMax != null @@ -330,6 +348,9 @@ async function plainPageResponse( orderBy: [{ [sortBy]: sortDir }, { id: sortDir }], take: limit, skip: offset, + // Named columns, so a column added to the model (a token link) reaches + // the wire only through this select. + select: MEASUREMENT_WIRE_SELECT, }), prisma.measurement.count({ where }), ]); @@ -339,7 +360,7 @@ async function plainPageResponse( return apiSuccess({ // Decrypt `notesEncrypted` onto `notes` and strip the ciphertext before // it leaves the server (iOS MeasurementWireDTO contract). - measurements: measurements.map(shapeMeasurementNotes), + measurements: measurements.map(wireRow), meta: { total, limit, offset }, }); } diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index 93f9322de..0117ad3a6 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -394,6 +394,10 @@ export const listMeasurementsSchema = z // threaded into the plain-list `where`. Backed by the `(userId, source, // measuredAt)` index (migration 0136). sourceEq: measurementSourceEnum.optional(), + // Only the rows the HealthLog app writes into Apple Health + // (`appleHealthMirror`, #1272): the app's historical backfill pages this + // instead of one fetch per source. A plain-list filter. + appleHealthMirror: z.enum(["true"]).optional(), // v1.18.5 — value-range FILTER (backlog G). Narrows the plain list to // readings whose `value` falls within an optional `[valueMin, valueMax]` // band. Either bound may be omitted for an open-ended range. Coerced @@ -478,6 +482,23 @@ export const listMeasurementsSchema = z message: "valueMin must be <= valueMax", path: ["valueMin"], }, + ) + // `appleHealthMirror` narrows the plain row list. The other modes answer + // something else (night rows, day buckets, a drill-down, a series), where + // a row filter would either do nothing or change what the mode means. + .refine( + ({ appleHealthMirror, type, dayKey, groupBy, aggregate, source }) => + appleHealthMirror === undefined || + (type !== "SLEEP_DURATION" && + dayKey === undefined && + groupBy === undefined && + source === undefined && + (aggregate === undefined || aggregate === "raw")), + { + message: + "appleHealthMirror filters the plain row list only; combine it with type, from, to, sourceEq and paging", + path: ["appleHealthMirror"], + }, ); export const createBatchMeasurementSchema = z.object({ diff --git a/tests/integration/__golden__/backup-parity/export-disaster-recovery.json b/tests/integration/__golden__/backup-parity/export-disaster-recovery.json index e34d5552b..b14db8aff 100644 --- a/tests/integration/__golden__/backup-parity/export-disaster-recovery.json +++ b/tests/integration/__golden__/backup-parity/export-disaster-recovery.json @@ -194,6 +194,7 @@ "sleepStage": null, "rhythmClassification": null, "deviceType": null, + "ingestTokenId": null, "syncVersion": 1, "deletedAt": null, "createdAt": "2026-05-01T08:20:03.000Z", @@ -217,6 +218,7 @@ "sleepStage": "DEEP", "rhythmClassification": null, "deviceType": null, + "ingestTokenId": null, "syncVersion": 1, "deletedAt": null, "createdAt": "2026-05-01T08:20:04.000Z", @@ -240,6 +242,7 @@ "sleepStage": null, "rhythmClassification": null, "deviceType": null, + "ingestTokenId": null, "syncVersion": 4, "deletedAt": "", "createdAt": "2026-05-01T08:20:01.000Z", @@ -263,6 +266,7 @@ "sleepStage": null, "rhythmClassification": null, "deviceType": "rt-Measurement-deviceType", + "ingestTokenId": null, "syncVersion": 1, "deletedAt": null, "createdAt": "2026-05-01T08:20:00.000Z", @@ -286,6 +290,7 @@ "sleepStage": null, "rhythmClassification": "IRREGULAR", "deviceType": null, + "ingestTokenId": null, "syncVersion": 1, "deletedAt": null, "createdAt": "2026-05-01T08:20:02.000Z", @@ -2629,6 +2634,7 @@ "sleepStage": null, "rhythmClassification": null, "deviceType": null, + "ingestTokenId": null, "syncVersion": 1, "deletedAt": null, "createdAt": "2026-05-01T08:20:03.000Z", @@ -2652,6 +2658,7 @@ "sleepStage": "DEEP", "rhythmClassification": null, "deviceType": null, + "ingestTokenId": null, "syncVersion": 1, "deletedAt": null, "createdAt": "2026-05-01T08:20:04.000Z", @@ -2675,6 +2682,7 @@ "sleepStage": null, "rhythmClassification": null, "deviceType": null, + "ingestTokenId": null, "syncVersion": 4, "deletedAt": "", "createdAt": "2026-05-01T08:20:01.000Z", @@ -2698,6 +2706,7 @@ "sleepStage": null, "rhythmClassification": null, "deviceType": "rt-Measurement-deviceType", + "ingestTokenId": null, "syncVersion": 1, "deletedAt": null, "createdAt": "2026-05-01T08:20:00.000Z", @@ -2721,6 +2730,7 @@ "sleepStage": null, "rhythmClassification": "IRREGULAR", "deviceType": null, + "ingestTokenId": null, "syncVersion": 1, "deletedAt": null, "createdAt": "2026-05-01T08:20:02.000Z", diff --git a/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json b/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json index 7c7b4eb59..f8e15f9f9 100644 --- a/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json +++ b/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json @@ -428,7 +428,8 @@ "syncVersion": 1, "deletedAt": null, "createdAt": "2026-05-01T08:20:03.000Z", - "updatedAt": "2026-05-01T08:21:03.000Z" + "updatedAt": "2026-05-01T08:21:03.000Z", + "ingestTokenId": null }, { "id": "", @@ -452,7 +453,8 @@ "syncVersion": 1, "deletedAt": null, "createdAt": "2026-05-01T08:20:02.000Z", - "updatedAt": "2026-05-01T08:21:02.000Z" + "updatedAt": "2026-05-01T08:21:02.000Z", + "ingestTokenId": null }, { "id": "", @@ -476,7 +478,8 @@ "syncVersion": 4, "deletedAt": "", "createdAt": "2026-05-01T08:20:01.000Z", - "updatedAt": "2026-05-01T08:21:01.000Z" + "updatedAt": "2026-05-01T08:21:01.000Z", + "ingestTokenId": null }, { "id": "", @@ -500,7 +503,8 @@ "syncVersion": 1, "deletedAt": null, "createdAt": "2026-05-01T08:20:04.000Z", - "updatedAt": "2026-05-01T08:21:04.000Z" + "updatedAt": "2026-05-01T08:21:04.000Z", + "ingestTokenId": null }, { "id": "", @@ -524,7 +528,8 @@ "syncVersion": 1, "deletedAt": null, "createdAt": "2026-05-01T08:20:00.000Z", - "updatedAt": "2026-05-01T08:21:00.000Z" + "updatedAt": "2026-05-01T08:21:00.000Z", + "ingestTokenId": null } ], "IntradayCumulativeProfile": [ @@ -3091,6 +3096,7 @@ "sleepStage": null, "rhythmClassification": null, "deviceType": null, + "ingestTokenId": null, "syncVersion": 1, "deletedAt": null, "createdAt": "2026-05-01T08:20:03.000Z", @@ -3114,6 +3120,7 @@ "sleepStage": "DEEP", "rhythmClassification": null, "deviceType": null, + "ingestTokenId": null, "syncVersion": 1, "deletedAt": null, "createdAt": "2026-05-01T08:20:04.000Z", @@ -3137,6 +3144,7 @@ "sleepStage": null, "rhythmClassification": null, "deviceType": null, + "ingestTokenId": null, "syncVersion": 4, "deletedAt": "", "createdAt": "2026-05-01T08:20:01.000Z", @@ -3160,6 +3168,7 @@ "sleepStage": null, "rhythmClassification": null, "deviceType": "rt-Measurement-deviceType", + "ingestTokenId": null, "syncVersion": 1, "deletedAt": null, "createdAt": "2026-05-01T08:20:00.000Z", @@ -3183,6 +3192,7 @@ "sleepStage": null, "rhythmClassification": "IRREGULAR", "deviceType": null, + "ingestTokenId": null, "syncVersion": 1, "deletedAt": null, "createdAt": "2026-05-01T08:20:02.000Z", diff --git a/tests/integration/__golden__/backup-parity/restore-portable.json b/tests/integration/__golden__/backup-parity/restore-portable.json index 5b81b82e2..b7d8e05c3 100644 --- a/tests/integration/__golden__/backup-parity/restore-portable.json +++ b/tests/integration/__golden__/backup-parity/restore-portable.json @@ -520,7 +520,8 @@ "syncVersion": 1, "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "ingestTokenId": null }, { "id": "", @@ -544,7 +545,8 @@ "syncVersion": 1, "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "ingestTokenId": null }, { "id": "", @@ -568,7 +570,8 @@ "syncVersion": 1, "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "ingestTokenId": null }, { "id": "", @@ -592,7 +595,8 @@ "syncVersion": 1, "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "ingestTokenId": null } ], "IntradayCumulativeProfile": [ diff --git a/tests/integration/__golden__/backup-parity/restore-skips.json b/tests/integration/__golden__/backup-parity/restore-skips.json index 0861a6e8b..d050c0382 100644 --- a/tests/integration/__golden__/backup-parity/restore-skips.json +++ b/tests/integration/__golden__/backup-parity/restore-skips.json @@ -550,7 +550,8 @@ "syncVersion": 1, "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "ingestTokenId": null } ], "IntradayCumulativeProfile": [], diff --git a/tests/integration/__golden__/backup-parity/restore-version-1.json b/tests/integration/__golden__/backup-parity/restore-version-1.json index 7128d4c26..7c6570625 100644 --- a/tests/integration/__golden__/backup-parity/restore-version-1.json +++ b/tests/integration/__golden__/backup-parity/restore-version-1.json @@ -426,7 +426,8 @@ "syncVersion": 1, "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "ingestTokenId": null }, { "id": "", @@ -450,7 +451,8 @@ "syncVersion": 1, "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "ingestTokenId": null }, { "id": "", @@ -474,7 +476,8 @@ "syncVersion": 1, "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "ingestTokenId": null }, { "id": "", @@ -498,7 +501,8 @@ "syncVersion": 1, "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "ingestTokenId": null } ], "IntradayCumulativeProfile": [], @@ -1289,6 +1293,7 @@ "sleepStage": null, "rhythmClassification": null, "deviceType": null, + "ingestTokenId": null, "syncVersion": 1, "deletedAt": null, "createdAt": "", @@ -1312,6 +1317,7 @@ "sleepStage": null, "rhythmClassification": null, "deviceType": null, + "ingestTokenId": null, "syncVersion": 1, "deletedAt": null, "createdAt": "", @@ -1335,6 +1341,7 @@ "sleepStage": null, "rhythmClassification": null, "deviceType": null, + "ingestTokenId": null, "syncVersion": 1, "deletedAt": null, "createdAt": "", @@ -1358,6 +1365,7 @@ "sleepStage": null, "rhythmClassification": null, "deviceType": null, + "ingestTokenId": null, "syncVersion": 1, "deletedAt": null, "createdAt": "", diff --git a/tests/integration/__golden__/backup-parity/seeded-account.json b/tests/integration/__golden__/backup-parity/seeded-account.json index 0a7bc1df0..c4d46b2c9 100644 --- a/tests/integration/__golden__/backup-parity/seeded-account.json +++ b/tests/integration/__golden__/backup-parity/seeded-account.json @@ -26,7 +26,8 @@ "syncVersion": 1, "deletedAt": null, "createdAt": "2026-05-01T08:20:03.000Z", - "updatedAt": "2026-05-01T08:21:03.000Z" + "updatedAt": "2026-05-01T08:21:03.000Z", + "ingestTokenId": null }, { "id": "", @@ -50,7 +51,8 @@ "syncVersion": 1, "deletedAt": null, "createdAt": "2026-05-01T08:20:02.000Z", - "updatedAt": "2026-05-01T08:21:02.000Z" + "updatedAt": "2026-05-01T08:21:02.000Z", + "ingestTokenId": null }, { "id": "", @@ -74,7 +76,8 @@ "syncVersion": 4, "deletedAt": "", "createdAt": "2026-05-01T08:20:01.000Z", - "updatedAt": "2026-05-01T08:21:01.000Z" + "updatedAt": "2026-05-01T08:21:01.000Z", + "ingestTokenId": null }, { "id": "", @@ -98,7 +101,8 @@ "syncVersion": 1, "deletedAt": null, "createdAt": "2026-05-01T08:20:04.000Z", - "updatedAt": "2026-05-01T08:21:04.000Z" + "updatedAt": "2026-05-01T08:21:04.000Z", + "ingestTokenId": null }, { "id": "", @@ -122,7 +126,8 @@ "syncVersion": 1, "deletedAt": null, "createdAt": "2026-05-01T08:20:00.000Z", - "updatedAt": "2026-05-01T08:21:00.000Z" + "updatedAt": "2026-05-01T08:21:00.000Z", + "ingestTokenId": null } ], "IntradayCumulativeProfile": [ diff --git a/tests/integration/backup-token-link-restore.test.ts b/tests/integration/backup-token-link-restore.test.ts new file mode 100644 index 000000000..f5881593b --- /dev/null +++ b/tests/integration/backup-token-link-restore.test.ts @@ -0,0 +1,131 @@ +/** + * A backup carries the token link of an EXTERNAL reading, and a restore keeps + * it only where it still means something (#1272). + * + * Tokens are not part of a backup. A reading linked to a token the restoring + * account still holds comes back linked, so its Apple Health write-back is + * still that token's setting; a reading whose token is gone comes back with no + * link rather than a dangling id. The file is built by the real writer. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { encrypt } from "@/lib/crypto"; +import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; +import { parseBackupPayload } from "@/lib/validations/backup"; +import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes"; +import { POST as RESTORE } from "./restore-job-driver"; + +import { makeToken } from "../kit/factories"; +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } 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), +})); + +vi.mock("@/lib/cache/invalidate", async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, invalidateUserData: vi.fn() }; +}); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +describe("restoring EXTERNAL readings", () => { + it("keeps the link to a token the account holds and drops the rest", async () => { + const prisma = getPrismaClient(); + const owner = await prisma.user.create({ + data: { + username: "token-link-owner", + email: "token-link-owner@example.test", + role: "ADMIN", + }, + }); + const session = await prisma.session.create({ + data: { userId: owner.id, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + + const kept = await makeToken(owner, { + permissions: [MEASUREMENTS_WRITE_SCOPE], + }); + const gone = await makeToken(owner, { + permissions: [MEASUREMENTS_WRITE_SCOPE], + }); + const reading = (day: string, ingestTokenId: string | null) => ({ + userId: owner.id, + type: "WEIGHT" as const, + source: "EXTERNAL" as const, + value: 80, + unit: "kg", + measuredAt: new Date(`${day}T06:00:00.000Z`), + ingestTokenId, + }); + await prisma.measurement.createMany({ + data: [ + reading("2026-10-01", kept.tokenId), + reading("2026-10-02", gone.tokenId), + reading("2026-10-03", null), + ], + }); + + const { payload: built } = await buildFullBackupPayload(prisma, owner.id, { + purpose: "disaster-recovery", + }); + const parsed = parseBackupPayload(JSON.stringify(built)); + const backup = await prisma.dataBackup.create({ + data: { + userId: owner.id, + type: "TOKEN_LINK_ROUND_TRIP", + data: encrypt(JSON.stringify(parsed)), + }, + }); + await prisma.measurement.deleteMany({ where: { userId: owner.id } }); + await prisma.apiToken.delete({ where: { id: gone.tokenId } }); + + const response = await RESTORE( + new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "RESTORE" }), + }) as unknown as Parameters[0], + { params: Promise.resolve({ id: backup.id }) }, + ); + expect(response.status).toBe(200); + + const restored = await prisma.measurement.findMany({ + where: { userId: owner.id }, + select: { measuredAt: true, ingestTokenId: true }, + orderBy: { measuredAt: "asc" }, + }); + expect(restored.map((r) => r.ingestTokenId)).toEqual([ + kept.tokenId, + null, + null, + ]); + }); +}); diff --git a/tests/integration/routes/parity/__snapshots__/measurements.get-01-page.json.snap b/tests/integration/routes/parity/__snapshots__/measurements.get-01-page.json.snap index 9c445be46..d9d783512 100644 --- a/tests/integration/routes/parity/__snapshots__/measurements.get-01-page.json.snap +++ b/tests/integration/routes/parity/__snapshots__/measurements.get-01-page.json.snap @@ -24,7 +24,8 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "appleHealthMirror": true }, { "id": "", @@ -47,7 +48,8 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "appleHealthMirror": false }, { "id": "", @@ -70,7 +72,8 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "appleHealthMirror": false }, { "id": "", @@ -93,7 +96,8 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "appleHealthMirror": true }, { "id": "", @@ -116,7 +120,8 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "appleHealthMirror": true }, { "id": "", @@ -139,7 +144,8 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "appleHealthMirror": true }, { "id": "", @@ -162,7 +168,8 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "appleHealthMirror": true }, { "id": "", @@ -185,7 +192,8 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "appleHealthMirror": true }, { "id": "", @@ -208,7 +216,8 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "appleHealthMirror": true }, { "id": "", @@ -231,7 +240,8 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "appleHealthMirror": false }, { "id": "", @@ -254,7 +264,8 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "appleHealthMirror": false } ], "meta": { diff --git a/tests/integration/routes/parity/__snapshots__/measurements.get-02-type-sorted.json.snap b/tests/integration/routes/parity/__snapshots__/measurements.get-02-type-sorted.json.snap index 825225714..616f38074 100644 --- a/tests/integration/routes/parity/__snapshots__/measurements.get-02-type-sorted.json.snap +++ b/tests/integration/routes/parity/__snapshots__/measurements.get-02-type-sorted.json.snap @@ -24,7 +24,8 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "appleHealthMirror": true }, { "id": "", @@ -47,7 +48,8 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "appleHealthMirror": true } ], "meta": { diff --git a/tests/integration/routes/parity/__snapshots__/measurements.get-03-filtered.json.snap b/tests/integration/routes/parity/__snapshots__/measurements.get-03-filtered.json.snap index 41d0feadc..ff7169b01 100644 --- a/tests/integration/routes/parity/__snapshots__/measurements.get-03-filtered.json.snap +++ b/tests/integration/routes/parity/__snapshots__/measurements.get-03-filtered.json.snap @@ -24,7 +24,8 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "appleHealthMirror": true }, { "id": "", @@ -47,7 +48,8 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "appleHealthMirror": true } ], "meta": { diff --git a/tests/integration/routes/parity/__snapshots__/measurements.get-05-drill-down.json.snap b/tests/integration/routes/parity/__snapshots__/measurements.get-05-drill-down.json.snap index 9867be855..9200d8751 100644 --- a/tests/integration/routes/parity/__snapshots__/measurements.get-05-drill-down.json.snap +++ b/tests/integration/routes/parity/__snapshots__/measurements.get-05-drill-down.json.snap @@ -24,7 +24,8 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "appleHealthMirror": false } ], "meta": { diff --git a/tests/integration/routes/parity/__snapshots__/measurements.post-01-single.json.snap b/tests/integration/routes/parity/__snapshots__/measurements.post-01-single.json.snap index 28e0b3bfc..a7bbb2a73 100644 --- a/tests/integration/routes/parity/__snapshots__/measurements.post-01-single.json.snap +++ b/tests/integration/routes/parity/__snapshots__/measurements.post-01-single.json.snap @@ -22,7 +22,8 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "appleHealthMirror": true }, "error": null }, @@ -73,7 +74,9 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "ingestTokenId": null, + "ingestToken": null } ], "manual" diff --git a/tests/integration/routes/parity/__snapshots__/measurements.post-04-single-glucose.json.snap b/tests/integration/routes/parity/__snapshots__/measurements.post-04-single-glucose.json.snap index 29f7eb5da..faa1d778d 100644 --- a/tests/integration/routes/parity/__snapshots__/measurements.post-04-single-glucose.json.snap +++ b/tests/integration/routes/parity/__snapshots__/measurements.post-04-single-glucose.json.snap @@ -22,7 +22,8 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "appleHealthMirror": true }, "error": null }, @@ -73,7 +74,9 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "ingestTokenId": null, + "ingestToken": null } ], "manual" diff --git a/tests/integration/routes/parity/__snapshots__/measurements.post-05-batch.json.snap b/tests/integration/routes/parity/__snapshots__/measurements.post-05-batch.json.snap index 4227a1ed3..47fe5b03b 100644 --- a/tests/integration/routes/parity/__snapshots__/measurements.post-05-batch.json.snap +++ b/tests/integration/routes/parity/__snapshots__/measurements.post-05-batch.json.snap @@ -23,7 +23,8 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "appleHealthMirror": true }, { "id": "", @@ -46,7 +47,8 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "appleHealthMirror": true }, { "id": "", @@ -69,7 +71,8 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "appleHealthMirror": true } ], "error": null @@ -126,7 +129,9 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "ingestTokenId": null, + "ingestToken": null }, { "id": "", @@ -150,7 +155,9 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "ingestTokenId": null, + "ingestToken": null }, { "id": "", @@ -174,7 +181,9 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "ingestTokenId": null, + "ingestToken": null } ], "manual" diff --git a/tests/integration/routes/parity/__snapshots__/measurements.post-08-sleep-stage.json.snap b/tests/integration/routes/parity/__snapshots__/measurements.post-08-sleep-stage.json.snap index 8dd26af54..6c9eb9385 100644 --- a/tests/integration/routes/parity/__snapshots__/measurements.post-08-sleep-stage.json.snap +++ b/tests/integration/routes/parity/__snapshots__/measurements.post-08-sleep-stage.json.snap @@ -23,6 +23,7 @@ "deletedAt": null, "createdAt": "", "updatedAt": "", + "appleHealthMirror": true, "status": "inserted" }, "error": null diff --git a/tests/integration/routes/parity/__snapshots__/measurements.post-09-sleep-stage-again.json.snap b/tests/integration/routes/parity/__snapshots__/measurements.post-09-sleep-stage-again.json.snap index 8c9e12f6d..43ba4abc3 100644 --- a/tests/integration/routes/parity/__snapshots__/measurements.post-09-sleep-stage-again.json.snap +++ b/tests/integration/routes/parity/__snapshots__/measurements.post-09-sleep-stage-again.json.snap @@ -23,6 +23,7 @@ "deletedAt": null, "createdAt": "", "updatedAt": "", + "appleHealthMirror": true, "status": "duplicate" }, "error": null diff --git a/tests/integration/routes/parity/__snapshots__/measurements.post-10-sleep-stage-mirror.json.snap b/tests/integration/routes/parity/__snapshots__/measurements.post-10-sleep-stage-mirror.json.snap index 1e51a4d35..b1e4c0e4e 100644 --- a/tests/integration/routes/parity/__snapshots__/measurements.post-10-sleep-stage-mirror.json.snap +++ b/tests/integration/routes/parity/__snapshots__/measurements.post-10-sleep-stage-mirror.json.snap @@ -23,6 +23,7 @@ "deletedAt": null, "createdAt": "", "updatedAt": "", + "appleHealthMirror": true, "status": "duplicate" }, "error": null diff --git a/tests/integration/routes/parity/__snapshots__/measurements.post-13-bridge.json.snap b/tests/integration/routes/parity/__snapshots__/measurements.post-13-bridge.json.snap index 1b16eb525..72345bc88 100644 --- a/tests/integration/routes/parity/__snapshots__/measurements.post-13-bridge.json.snap +++ b/tests/integration/routes/parity/__snapshots__/measurements.post-13-bridge.json.snap @@ -22,7 +22,8 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "appleHealthMirror": false }, "error": null }, @@ -73,7 +74,11 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "ingestTokenId": "", + "ingestToken": { + "mirrorToAppleHealth": false + } } ], "manual" @@ -111,7 +116,7 @@ ], "stored": [ { - "id": "", + "id": "", "type": "WEIGHT", "value": 80.4, "unit": "kg", @@ -141,7 +146,7 @@ "sealedNote": false }, { - "id": "", + "id": "", "type": "BLOOD_PRESSURE_SYS", "value": 128, "unit": "mmHg", @@ -156,7 +161,7 @@ "sealedNote": false }, { - "id": "", + "id": "", "type": "BLOOD_PRESSURE_DIA", "value": 82, "unit": "mmHg", @@ -171,7 +176,7 @@ "sealedNote": false }, { - "id": "", + "id": "", "type": "PULSE", "value": 66, "unit": "bpm", @@ -186,7 +191,7 @@ "sealedNote": true }, { - "id": "", + "id": "", "type": "SLEEP_DURATION", "value": 90, "unit": "minutes", @@ -201,7 +206,7 @@ "sealedNote": false }, { - "id": "", + "id": "", "type": "BLOOD_GLUCOSE", "value": 104, "unit": "mg/dL", diff --git a/tests/integration/routes/parity/__snapshots__/measurements.post-14-delegated.json.snap b/tests/integration/routes/parity/__snapshots__/measurements.post-14-delegated.json.snap index acf18c4fc..df04db8bf 100644 --- a/tests/integration/routes/parity/__snapshots__/measurements.post-14-delegated.json.snap +++ b/tests/integration/routes/parity/__snapshots__/measurements.post-14-delegated.json.snap @@ -22,7 +22,8 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "appleHealthMirror": true }, "error": null }, @@ -73,7 +74,9 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "ingestTokenId": null, + "ingestToken": null } ], "manual" diff --git a/tests/integration/token-link-backfill-migration.test.ts b/tests/integration/token-link-backfill-migration.test.ts new file mode 100644 index 000000000..19a0c46bc --- /dev/null +++ b/tests/integration/token-link-backfill-migration.test.ts @@ -0,0 +1,132 @@ +/** + * Migration 0476 links existing EXTERNAL readings to the token that carried + * them only where that is provable (#1272): the account holds exactly one + * measurement token, revoked ones counted, and the reading is no older than + * the token. Everything else stays unlinked. The pass writes nothing but the + * link, so no reading's `updatedAt` or `syncVersion` moves, and a rerun + * changes nothing. + */ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes"; + +import { makeToken, makeUser } from "../kit/factories"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const prisma = getPrismaClient(); + +const MIGRATION = readFileSync( + resolve( + process.cwd(), + "prisma", + "migrations", + "0476_token_apple_health_mirror", + "migration.sql", + ), + "utf8", +); + +/** The file is three `DO` blocks; each goes through as its own statement. */ +async function runMigration(): Promise { + const blocks = MIGRATION.match(/DO \$migration\$[\s\S]*?\$migration\$;/g); + expect(blocks).toHaveLength(3); + for (const block of blocks!) await prisma.$executeRawUnsafe(block); +} + +const TOKEN_AT = new Date("2026-06-01T00:00:00.000Z"); + +async function externalReading( + userId: string, + createdAt: Date, + source: "EXTERNAL" | "MANUAL" = "EXTERNAL", +) { + return prisma.measurement.create({ + data: { + userId, + type: "WEIGHT", + value: 80, + unit: "kg", + source, + measuredAt: createdAt, + createdAt, + updatedAt: new Date("2026-07-01T00:00:00.000Z"), + }, + select: { id: true }, + }); +} + +async function measurementToken(userId: string, revoked = false) { + const token = await makeToken( + { id: userId }, + { permissions: [MEASUREMENTS_WRITE_SCOPE] }, + ); + await prisma.apiToken.update({ + where: { id: token.tokenId }, + data: { createdAt: TOKEN_AT, revoked }, + }); + return token.tokenId; +} + +beforeEach(async () => { + await truncateAllTables(prisma); +}); + +describe("migration 0476 backfill", () => { + it("links only what one token provably carried", async () => { + const sole = await makeUser({ timezone: "UTC" }); + const soleToken = await measurementToken(sole.id, true); + // A wildcard sign-in token does not make the measurement token ambiguous. + await makeToken(sole, { permissions: ["*"] }); + const after = await externalReading( + sole.id, + new Date("2026-06-02T08:00:00.000Z"), + ); + const before = await externalReading( + sole.id, + new Date("2026-05-30T08:00:00.000Z"), + ); + const manual = await externalReading( + sole.id, + new Date("2026-06-03T08:00:00.000Z"), + "MANUAL", + ); + + const two = await makeUser({ timezone: "UTC" }); + await measurementToken(two.id); + await measurementToken(two.id); + const ambiguous = await externalReading( + two.id, + new Date("2026-06-02T08:00:00.000Z"), + ); + + const stamps = await prisma.measurement.findMany({ + select: { id: true, updatedAt: true, syncVersion: true }, + orderBy: { id: "asc" }, + }); + + await runMigration(); + await runMigration(); + + const links = Object.fromEntries( + ( + await prisma.measurement.findMany({ + select: { id: true, ingestTokenId: true }, + }) + ).map((r) => [r.id, r.ingestTokenId]), + ); + expect(links).toEqual({ + [after.id]: soleToken, + [before.id]: null, + [manual.id]: null, + [ambiguous.id]: null, + }); + expect( + await prisma.measurement.findMany({ + select: { id: true, updatedAt: true, syncVersion: true }, + orderBy: { id: "asc" }, + }), + ).toEqual(stamps); + }); +}); From aab2e0ae7d8041f403ec4b352cfe6092a22ef32f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 19:28:04 +0200 Subject: [PATCH 07/10] feat(sync): re-deliver Apple Health write-back changes Measurement upserts in /api/sync/changes carry appleHealthMirror. A new measurementMirror domain delivers { id, appleHealthMirror } for the live readings of every token whose setting changed past the cursor, keyed on (token.mirrorChangedAt, measurement.id). Switching the setting writes the token row only, so the readings keep their updatedAt and syncVersion and nothing keyed on them recomputes. The domain is additive inside the v2 cursor and is left out of the retention expiry, since its watermark is a setting's age, not a deletion's. --- .../changes/__tests__/module-gate.test.ts | 35 ++--- src/lib/sync/changes/domain-pages.ts | 16 +++ src/lib/sync/changes/pull.ts | 35 ++++- src/lib/sync/changes/read-rows.ts | 123 +++++++++++++----- src/lib/sync/changes/wire.ts | 13 ++ src/lib/sync/cursor.ts | 19 ++- .../sync-changes.01-first-page.json.snap | 7 + .../sync-changes.02-second-page.json.snap | 6 + .../sync-changes.03-the-rest.json.snap | 6 + .../sync-changes.04-caught-up.json.snap | 5 + .../sync-changes.05-past-retention.json.snap | 4 + .../sync-changes.06-before-reset.json.snap | 4 + .../sync-changes.07-cycle-off.json.snap | 9 ++ 13 files changed, 226 insertions(+), 56 deletions(-) diff --git a/src/app/api/sync/changes/__tests__/module-gate.test.ts b/src/app/api/sync/changes/__tests__/module-gate.test.ts index 5b5e004c9..e75cd7145 100644 --- a/src/app/api/sync/changes/__tests__/module-gate.test.ts +++ b/src/app/api/sync/changes/__tests__/module-gate.test.ts @@ -106,22 +106,25 @@ beforeEach(() => { } as never); // Non-cycle domains always return rows so a 403/empty-everything result // cannot be mistaken for a passing "cycle omitted" assertion. - vi.mocked(prisma.measurement.findMany).mockResolvedValue([ - { - id: "m-1", - externalId: "ext-m-1", - type: "WEIGHT", - value: 80, - unit: "kg", - measuredAt: NOW, - source: "MANUAL", - notes: null, - notesEncrypted: null, - syncVersion: 1, - deletedAt: null, - updatedAt: NOW, - }, - ] as never); + // The `measurementMirror` walk reads measurements too (the readings of + // tokens whose Apple Health setting changed); it finds none here. + const measurementRow = { + id: "m-1", + externalId: "ext-m-1", + type: "WEIGHT", + value: 80, + unit: "kg", + measuredAt: NOW, + source: "MANUAL", + notes: null, + notesEncrypted: null, + syncVersion: 1, + deletedAt: null, + updatedAt: NOW, + }; + vi.mocked(prisma.measurement.findMany).mockImplementation((async (args: { + where?: { ingestToken?: unknown }; + }) => (args.where?.ingestToken ? [] : [measurementRow])) as never); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( [] as never, diff --git a/src/lib/sync/changes/domain-pages.ts b/src/lib/sync/changes/domain-pages.ts index a2100337c..84b3eaf64 100644 --- a/src/lib/sync/changes/domain-pages.ts +++ b/src/lib/sync/changes/domain-pages.ts @@ -9,6 +9,7 @@ import { loadFoldTimezone, } from "@/lib/measurements/folded-window"; import { readNote } from "@/lib/crypto/note-cipher"; +import { resolveAppleHealthMirror } from "@/lib/measurements/apple-health-mirror"; import { levelAForWire } from "@/lib/mood/level-a"; import { contextForWire } from "@/lib/mood/context"; import { @@ -22,6 +23,7 @@ import type { CycleDayRow, CycleRow, IntakeRow, + MeasurementMirrorRow, MeasurementRow, MoodRow, } from "./read-rows"; @@ -30,6 +32,7 @@ import type { DomainBlock, IdTombstone, IntakeUpsert, + MeasurementMirrorUpsert, MeasurementTombstone, MeasurementUpsert, MoodUpsert, @@ -129,6 +132,7 @@ export async function measurementBlock( measuredAt: row.measuredAt.toISOString(), source: row.source, notes: readNote(row.notesEncrypted, row.notes), + appleHealthMirror: resolveAppleHealthMirror(row), syncVersion: row.syncVersion, updatedAt: row.updatedAt.toISOString(), }); @@ -137,6 +141,18 @@ export async function measurementBlock( return { upserts, tombstones }; } +export function measurementMirrorBlock( + page: MeasurementMirrorRow[], +): DomainBlock { + return { + upserts: page.map((row) => ({ + id: row.id, + appleHealthMirror: resolveAppleHealthMirror(row), + })), + tombstones: [], + }; +} + export function moodBlock( page: MoodRow[], ): DomainBlock { diff --git a/src/lib/sync/changes/pull.ts b/src/lib/sync/changes/pull.ts index 594ec3a3d..cae7aed19 100644 --- a/src/lib/sync/changes/pull.ts +++ b/src/lib/sync/changes/pull.ts @@ -25,6 +25,7 @@ import { cycleDayBlock, intakeBlock, measurementBlock, + measurementMirrorBlock, moodBlock, pageOf, } from "./domain-pages"; @@ -72,7 +73,11 @@ async function expiry( const retentionHorizon = new Date( serverNow.getTime() - TOMBSTONE_RETENTION_DAYS * DAY_MS, ); + // Not `measurementMirror`: its watermark is when a token's setting last + // changed, which can be any age, and the domain re-delivers values, not + // deletions, so no pruned tombstone can hide behind it. const pastRetention = SYNC_DOMAINS.some((domain) => { + if (domain === "measurementMirror") return false; const wm = cursor[domain]; return wm !== undefined && wm.updatedAtMs < retentionHorizon.getTime(); }); @@ -99,6 +104,7 @@ function emptyChanges(): SyncChangesBody["changes"] { intakes: { upserts: [], tombstones: [] }, cycleDays: { upserts: [], tombstones: [] }, cycles: { upserts: [], tombstones: [] }, + measurementMirror: { upserts: [], tombstones: [] }, }; } @@ -186,8 +192,25 @@ export async function pullSyncChanges(input: { const cycles = cycleBlock(cy.page); advanceCursor(nextCursor, "cycles", cy.page); + const mm = pageOf(rows.measurementMirror, limit); + const measurementMirror = measurementMirrorBlock(mm.page); + advanceCursor( + nextCursor, + "measurementMirror", + mm.page.map((row) => ({ + // The walk only returns rows whose token carries a change stamp. + updatedAt: row.ingestToken!.mirrorChangedAt!, + id: row.id, + })), + ); + const hasMore = - m.hasMore || md.hasMore || ik.hasMore || cd.hasMore || cy.hasMore; + m.hasMore || + md.hasMore || + ik.hasMore || + cd.hasMore || + cy.hasMore || + mm.hasMore; annotate({ action: { name: "sync.changes.pull" }, @@ -202,6 +225,7 @@ export async function pullSyncChanges(input: { cycle_day_tombstones: cycleDays.tombstones.length, cycle_upserts: cycles.upserts.length, cycle_tombstones: cycles.tombstones.length, + measurement_mirror_upserts: measurementMirror.upserts.length, cycle_module_enabled: cycleEnabled, has_more: hasMore, cursor_present: Boolean(token), @@ -213,6 +237,13 @@ export async function pullSyncChanges(input: { cursor: encodeCursor(nextCursor, serverNow.getTime()), hasMore, cursorExpired: false, - changes: { measurements, mood, intakes, cycleDays, cycles }, + changes: { + measurements, + mood, + intakes, + cycleDays, + cycles, + measurementMirror, + }, }; } diff --git a/src/lib/sync/changes/read-rows.ts b/src/lib/sync/changes/read-rows.ts index 9beae88f4..b23595c79 100644 --- a/src/lib/sync/changes/read-rows.ts +++ b/src/lib/sync/changes/read-rows.ts @@ -10,6 +10,7 @@ import { prisma } from "@/lib/db"; import type { Prisma } from "@/generated/prisma/client"; import type { DomainWatermark, SyncCursor } from "@/lib/sync/cursor"; import { dayLogSymptomInclude } from "@/lib/cycle/dto"; +import { APPLE_HEALTH_MIRROR_SELECT } from "@/lib/measurements/apple-health-mirror"; const MEASUREMENT_SELECT = { id: true, @@ -24,6 +25,13 @@ const MEASUREMENT_SELECT = { syncVersion: true, deletedAt: true, updatedAt: true, + ingestToken: APPLE_HEALTH_MIRROR_SELECT.ingestToken, +} satisfies Prisma.MeasurementSelect; + +const MEASUREMENT_MIRROR_SELECT = { + id: true, + source: true, + ingestToken: { select: { mirrorToAppleHealth: true, mirrorChangedAt: true } }, } satisfies Prisma.MeasurementSelect; const MOOD_SELECT = { @@ -73,6 +81,9 @@ export type CycleDayRow = Prisma.CycleDayLogGetPayload<{ include: typeof dayLogSymptomInclude; }>; export type CycleRow = Prisma.MenstrualCycleGetPayload; +export type MeasurementMirrorRow = Prisma.MeasurementGetPayload<{ + select: typeof MEASUREMENT_MIRROR_SELECT; +}>; /** * Build the `(updatedAt, id)` keyset predicate for a domain's watermark. @@ -95,6 +106,44 @@ export interface DomainRows { intakes: IntakeRow[]; cycleDays: CycleDayRow[]; cycles: CycleRow[]; + measurementMirror: MeasurementMirrorRow[]; +} + +/** + * The live readings of every token whose Apple Health setting changed past + * the watermark, in `(token.mirrorChangedAt, measurement.id)` order. Switching + * the setting writes the token row only, so the readings keep their + * `updatedAt` (nothing keyed on it recomputes) and arrive through this walk + * instead of the measurement one. + */ +function readMeasurementMirrorRows( + userId: string, + wm: DomainWatermark | undefined, + limit: number, +): Promise { + const at = wm ? new Date(wm.updatedAtMs) : null; + return prisma.measurement.findMany({ + where: { + userId, + deletedAt: null, + ingestToken: { mirrorChangedAt: { not: null } }, + ...(at && + wm && { + OR: [ + { ingestToken: { mirrorChangedAt: { gt: at } } }, + { + AND: [ + { ingestToken: { mirrorChangedAt: at } }, + { id: { gt: wm.id } }, + ], + }, + ], + }), + }, + orderBy: [{ ingestToken: { mirrorChangedAt: "asc" } }, { id: "asc" }], + take: limit + 1, + select: MEASUREMENT_MIRROR_SELECT, + }); } /** @@ -108,40 +157,42 @@ export async function readDomainRows( limit: number, cycleEnabled: boolean, ): Promise { - const [measurements, mood, intakes, cycleDays, cycles] = await Promise.all([ - prisma.measurement.findMany({ - where: { userId, ...keysetFilter(cursor.measurements) }, - orderBy: [{ updatedAt: "asc" }, { id: "asc" }], - take: limit + 1, - select: MEASUREMENT_SELECT, - }), - prisma.moodEntry.findMany({ - where: { userId, ...keysetFilter(cursor.mood) }, - orderBy: [{ updatedAt: "asc" }, { id: "asc" }], - take: limit + 1, - select: MOOD_SELECT, - }), - prisma.medicationIntakeEvent.findMany({ - where: { userId, ...keysetFilter(cursor.intakes) }, - orderBy: [{ updatedAt: "asc" }, { id: "asc" }], - take: limit + 1, - select: INTAKE_SELECT, - }), - cycleEnabled - ? prisma.cycleDayLog.findMany({ - where: { userId, ...keysetFilter(cursor.cycleDays) }, - orderBy: [{ updatedAt: "asc" }, { id: "asc" }], - take: limit + 1, - include: dayLogSymptomInclude, - }) - : [], - cycleEnabled - ? prisma.menstrualCycle.findMany({ - where: { userId, ...keysetFilter(cursor.cycles) }, - orderBy: [{ updatedAt: "asc" }, { id: "asc" }], - take: limit + 1, - }) - : [], - ]); - return { measurements, mood, intakes, cycleDays, cycles }; + const [measurements, mood, intakes, cycleDays, cycles, measurementMirror] = + await Promise.all([ + prisma.measurement.findMany({ + where: { userId, ...keysetFilter(cursor.measurements) }, + orderBy: [{ updatedAt: "asc" }, { id: "asc" }], + take: limit + 1, + select: MEASUREMENT_SELECT, + }), + prisma.moodEntry.findMany({ + where: { userId, ...keysetFilter(cursor.mood) }, + orderBy: [{ updatedAt: "asc" }, { id: "asc" }], + take: limit + 1, + select: MOOD_SELECT, + }), + prisma.medicationIntakeEvent.findMany({ + where: { userId, ...keysetFilter(cursor.intakes) }, + orderBy: [{ updatedAt: "asc" }, { id: "asc" }], + take: limit + 1, + select: INTAKE_SELECT, + }), + cycleEnabled + ? prisma.cycleDayLog.findMany({ + where: { userId, ...keysetFilter(cursor.cycleDays) }, + orderBy: [{ updatedAt: "asc" }, { id: "asc" }], + take: limit + 1, + include: dayLogSymptomInclude, + }) + : [], + cycleEnabled + ? prisma.menstrualCycle.findMany({ + where: { userId, ...keysetFilter(cursor.cycles) }, + orderBy: [{ updatedAt: "asc" }, { id: "asc" }], + take: limit + 1, + }) + : [], + readMeasurementMirrorRows(userId, cursor.measurementMirror, limit), + ]); + return { measurements, mood, intakes, cycleDays, cycles, measurementMirror }; } diff --git a/src/lib/sync/changes/wire.ts b/src/lib/sync/changes/wire.ts index d4a95ed6b..cc01311df 100644 --- a/src/lib/sync/changes/wire.ts +++ b/src/lib/sync/changes/wire.ts @@ -19,6 +19,8 @@ export interface MeasurementUpsert { measuredAt: string; source: string; notes: string | null; + /** Whether the app writes the reading into Apple Health (#1272). */ + appleHealthMirror: boolean; syncVersion: number; updatedAt: string; } @@ -97,12 +99,23 @@ export interface DomainBlock { tombstones: T[]; } +/** + * A reading whose Apple Health write-back changed because its token's + * setting did (#1272). The reading itself did not change and is not re-sent. + */ +export interface MeasurementMirrorUpsert { + id: string; + appleHealthMirror: boolean; +} + export interface SyncChanges { measurements: DomainBlock; mood: DomainBlock; intakes: DomainBlock; cycleDays: DomainBlock; cycles: DomainBlock; + /** Never carries tombstones: a deleted reading is a measurement tombstone. */ + measurementMirror: DomainBlock; } export interface SyncChangesBody { diff --git a/src/lib/sync/cursor.ts b/src/lib/sync/cursor.ts index 3b207e9d5..4d8d1c161 100644 --- a/src/lib/sync/cursor.ts +++ b/src/lib/sync/cursor.ts @@ -28,19 +28,34 @@ /** The sync domains the feed serves. */ export type SyncDomain = - "measurements" | "mood" | "intakes" | "cycleDays" | "cycles"; + | "measurements" + | "mood" + | "intakes" + | "cycleDays" + | "cycles" + | "measurementMirror"; +/** + * `measurementMirror` (#1272) is additive inside the v2 envelope: a token + * without it decodes as before and that domain scans from the start. Its + * watermark is not a row's `updatedAt` but the `mirrorChangedAt` of the + * token whose readings were last re-delivered (see `read-rows.ts`). + */ export const SYNC_DOMAINS: readonly SyncDomain[] = [ "measurements", "mood", "intakes", "cycleDays", "cycles", + "measurementMirror", ]; /** A single domain's keyset position. */ export interface DomainWatermark { - /** `updatedAt` of the last drained row, as epoch milliseconds. */ + /** + * `updatedAt` of the last drained row, as epoch milliseconds; for + * `measurementMirror`, the `mirrorChangedAt` of that row's token. + */ updatedAtMs: number; /** `id` of the last drained row (cuid) — the keyset tie-breaker. */ id: string; diff --git a/tests/integration/routes/parity/__snapshots__/sync-changes.01-first-page.json.snap b/tests/integration/routes/parity/__snapshots__/sync-changes.01-first-page.json.snap index cac388d58..6e7ef10b0 100644 --- a/tests/integration/routes/parity/__snapshots__/sync-changes.01-first-page.json.snap +++ b/tests/integration/routes/parity/__snapshots__/sync-changes.01-first-page.json.snap @@ -18,6 +18,7 @@ "measuredAt": "2026-10-14T06:30:00.000Z", "source": "MANUAL", "notes": "after the run", + "appleHealthMirror": true, "syncVersion": "", "updatedAt": "" }, @@ -30,6 +31,7 @@ "measuredAt": "2026-10-14T07:00:00.000Z", "source": "WITHINGS", "notes": null, + "appleHealthMirror": true, "syncVersion": "", "updatedAt": "" } @@ -197,6 +199,10 @@ } ], "tombstones": [] + }, + "measurementMirror": { + "upserts": [], + "tombstones": [] } } }, @@ -219,6 +225,7 @@ "cycle_day_tombstones": 0, "cycle_upserts": 2, "cycle_tombstones": 0, + "measurement_mirror_upserts": 0, "cycle_module_enabled": true, "has_more": true, "cursor_present": false diff --git a/tests/integration/routes/parity/__snapshots__/sync-changes.02-second-page.json.snap b/tests/integration/routes/parity/__snapshots__/sync-changes.02-second-page.json.snap index 96ebe0b69..735d06770 100644 --- a/tests/integration/routes/parity/__snapshots__/sync-changes.02-second-page.json.snap +++ b/tests/integration/routes/parity/__snapshots__/sync-changes.02-second-page.json.snap @@ -18,6 +18,7 @@ "measuredAt": "2026-06-17T09:30:00.000Z", "source": "APPLE_HEALTH", "notes": null, + "appleHealthMirror": false, "syncVersion": "", "updatedAt": "" } @@ -69,6 +70,10 @@ "updatedAt": "" } ] + }, + "measurementMirror": { + "upserts": [], + "tombstones": [] } } }, @@ -91,6 +96,7 @@ "cycle_day_tombstones": 1, "cycle_upserts": 0, "cycle_tombstones": 1, + "measurement_mirror_upserts": 0, "cycle_module_enabled": true, "has_more": true, "cursor_present": true diff --git a/tests/integration/routes/parity/__snapshots__/sync-changes.03-the-rest.json.snap b/tests/integration/routes/parity/__snapshots__/sync-changes.03-the-rest.json.snap index 88e5797a6..97841d250 100644 --- a/tests/integration/routes/parity/__snapshots__/sync-changes.03-the-rest.json.snap +++ b/tests/integration/routes/parity/__snapshots__/sync-changes.03-the-rest.json.snap @@ -18,6 +18,7 @@ "measuredAt": "2026-10-14T08:00:00.000Z", "source": "MANUAL", "notes": null, + "appleHealthMirror": true, "syncVersion": "", "updatedAt": "" } @@ -39,6 +40,10 @@ "cycles": { "upserts": [], "tombstones": [] + }, + "measurementMirror": { + "upserts": [], + "tombstones": [] } } }, @@ -61,6 +66,7 @@ "cycle_day_tombstones": 0, "cycle_upserts": 0, "cycle_tombstones": 0, + "measurement_mirror_upserts": 0, "cycle_module_enabled": true, "has_more": false, "cursor_present": true diff --git a/tests/integration/routes/parity/__snapshots__/sync-changes.04-caught-up.json.snap b/tests/integration/routes/parity/__snapshots__/sync-changes.04-caught-up.json.snap index d50f7efae..e74d39368 100644 --- a/tests/integration/routes/parity/__snapshots__/sync-changes.04-caught-up.json.snap +++ b/tests/integration/routes/parity/__snapshots__/sync-changes.04-caught-up.json.snap @@ -26,6 +26,10 @@ "cycles": { "upserts": [], "tombstones": [] + }, + "measurementMirror": { + "upserts": [], + "tombstones": [] } } }, @@ -48,6 +52,7 @@ "cycle_day_tombstones": 0, "cycle_upserts": 0, "cycle_tombstones": 0, + "measurement_mirror_upserts": 0, "cycle_module_enabled": true, "has_more": false, "cursor_present": true diff --git a/tests/integration/routes/parity/__snapshots__/sync-changes.05-past-retention.json.snap b/tests/integration/routes/parity/__snapshots__/sync-changes.05-past-retention.json.snap index 06894579d..237975e23 100644 --- a/tests/integration/routes/parity/__snapshots__/sync-changes.05-past-retention.json.snap +++ b/tests/integration/routes/parity/__snapshots__/sync-changes.05-past-retention.json.snap @@ -26,6 +26,10 @@ "cycles": { "upserts": [], "tombstones": [] + }, + "measurementMirror": { + "upserts": [], + "tombstones": [] } } }, diff --git a/tests/integration/routes/parity/__snapshots__/sync-changes.06-before-reset.json.snap b/tests/integration/routes/parity/__snapshots__/sync-changes.06-before-reset.json.snap index 360c66c24..52d0edffe 100644 --- a/tests/integration/routes/parity/__snapshots__/sync-changes.06-before-reset.json.snap +++ b/tests/integration/routes/parity/__snapshots__/sync-changes.06-before-reset.json.snap @@ -26,6 +26,10 @@ "cycles": { "upserts": [], "tombstones": [] + }, + "measurementMirror": { + "upserts": [], + "tombstones": [] } } }, diff --git a/tests/integration/routes/parity/__snapshots__/sync-changes.07-cycle-off.json.snap b/tests/integration/routes/parity/__snapshots__/sync-changes.07-cycle-off.json.snap index e26c893fc..f4c382042 100644 --- a/tests/integration/routes/parity/__snapshots__/sync-changes.07-cycle-off.json.snap +++ b/tests/integration/routes/parity/__snapshots__/sync-changes.07-cycle-off.json.snap @@ -18,6 +18,7 @@ "measuredAt": "2026-10-14T06:30:00.000Z", "source": "MANUAL", "notes": "after the run", + "appleHealthMirror": true, "syncVersion": "", "updatedAt": "" }, @@ -30,6 +31,7 @@ "measuredAt": "2026-10-14T07:00:00.000Z", "source": "WITHINGS", "notes": null, + "appleHealthMirror": true, "syncVersion": "", "updatedAt": "" }, @@ -42,6 +44,7 @@ "measuredAt": "2026-06-17T09:30:00.000Z", "source": "APPLE_HEALTH", "notes": null, + "appleHealthMirror": false, "syncVersion": "", "updatedAt": "" }, @@ -54,6 +57,7 @@ "measuredAt": "2026-10-14T08:00:00.000Z", "source": "MANUAL", "notes": null, + "appleHealthMirror": true, "syncVersion": "", "updatedAt": "" } @@ -158,6 +162,10 @@ "cycles": { "upserts": [], "tombstones": [] + }, + "measurementMirror": { + "upserts": [], + "tombstones": [] } } }, @@ -180,6 +188,7 @@ "cycle_day_tombstones": 0, "cycle_upserts": 0, "cycle_tombstones": 0, + "measurement_mirror_upserts": 0, "cycle_module_enabled": false, "has_more": false, "cursor_present": false From bfa6873d9605ecc805d14e1e3428220d5b802868 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 19:28:05 +0200 Subject: [PATCH 08/10] feat(tokens): add a per-token Apple Health setting PATCH /api/tokens/{id} sets mirrorToAppleHealth on a measurement token: cookie session only, the caller's own tokens only, revoked ones included, any other token refused with tokens.mirror.not_measurement_token. It stamps mirrorChangedAt and audits only when the value changes. The mint accepts the setting (off by default), the token list returns it, and GET /api/tokens/measurements counts the EXTERNAL readings no token reaches. --- src/app/api/tokens/[id]/route.ts | 65 +++- .../measurements/__tests__/route.test.ts | 42 ++- src/app/api/tokens/measurements/route.ts | 34 +- src/app/api/tokens/route.ts | 3 + src/lib/auth/issue-token.ts | 5 + src/lib/measurements/token-mirror.ts | 62 ++++ src/lib/openapi/error-codes.ts | 1 + src/lib/validations/tokens.ts | 15 + tests/integration/apple-health-mirror.test.ts | 351 ++++++++++++++++++ tests/integration/routes/kit-contract.test.ts | 1 + .../tokens-mirror.01-summary.json.snap | 68 ++++ .../tokens-mirror.02-on.json.snap | 78 ++++ .../tokens-mirror.03-on-again.json.snap | 78 ++++ .../tokens-mirror.04-list.json.snap | 109 ++++++ .../tokens-mirror.05-off-revoked.json.snap | 85 +++++ .../tokens-mirror.06-wildcard.json.snap | 83 +++++ .../tokens-mirror.07-connector.json.snap | 83 +++++ ...tokens-mirror.08-another-account.json.snap | 83 +++++ .../tokens-mirror.09-unknown.json.snap | 83 +++++ .../tokens-mirror.10-invalid-body.json.snap | 100 +++++ .../tokens-mirror.11-bearer.json.snap | 84 +++++ .../tokens-mirror.12-summary-bearer.json.snap | 84 +++++ .../parity/tokens-mirror.parity.test.ts | 238 ++++++++++++ 23 files changed, 1828 insertions(+), 7 deletions(-) create mode 100644 src/lib/measurements/token-mirror.ts create mode 100644 tests/integration/apple-health-mirror.test.ts create mode 100644 tests/integration/routes/parity/__snapshots__/tokens-mirror.01-summary.json.snap create mode 100644 tests/integration/routes/parity/__snapshots__/tokens-mirror.02-on.json.snap create mode 100644 tests/integration/routes/parity/__snapshots__/tokens-mirror.03-on-again.json.snap create mode 100644 tests/integration/routes/parity/__snapshots__/tokens-mirror.04-list.json.snap create mode 100644 tests/integration/routes/parity/__snapshots__/tokens-mirror.05-off-revoked.json.snap create mode 100644 tests/integration/routes/parity/__snapshots__/tokens-mirror.06-wildcard.json.snap create mode 100644 tests/integration/routes/parity/__snapshots__/tokens-mirror.07-connector.json.snap create mode 100644 tests/integration/routes/parity/__snapshots__/tokens-mirror.08-another-account.json.snap create mode 100644 tests/integration/routes/parity/__snapshots__/tokens-mirror.09-unknown.json.snap create mode 100644 tests/integration/routes/parity/__snapshots__/tokens-mirror.10-invalid-body.json.snap create mode 100644 tests/integration/routes/parity/__snapshots__/tokens-mirror.11-bearer.json.snap create mode 100644 tests/integration/routes/parity/__snapshots__/tokens-mirror.12-summary-bearer.json.snap create mode 100644 tests/integration/routes/parity/tokens-mirror.parity.test.ts diff --git a/src/app/api/tokens/[id]/route.ts b/src/app/api/tokens/[id]/route.ts index c6659d20d..9777d5aed 100644 --- a/src/app/api/tokens/[id]/route.ts +++ b/src/app/api/tokens/[id]/route.ts @@ -1,8 +1,12 @@ import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireAuth, requireCookieAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; -import { apiSuccess, apiError } from "@/lib/api-response"; +import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; import { isApiGloballyEnabled } from "@/lib/app-settings"; +import { parseJsonBody } from "@/lib/api/body"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { updateMeasurementTokenSchema } from "@/lib/validations/tokens"; +import { setMeasurementTokenMirror } from "@/lib/measurements/token-mirror"; type RouteParams = { params: Promise<{ id: string }> }; @@ -38,3 +42,60 @@ export const DELETE = apiHandler( return apiSuccess({ revoked: true }); }, ); + +/** + * Change a measurement token's one setting: whether the HealthLog app writes + * its readings into Apple Health (#1272). + * + * - Cookie only. A Bearer, the token's own included, cannot change what its + * readings are allowed to become; the person decides, from the browser + * they are sitting at. + * - The caller's own tokens only: another account's answers 404. + * - Measurement tokens only. A wildcard, an MCP or any other narrow token has + * no such setting and is refused with 422 `tokens.mirror.not_measurement_token`. + * - A revoked token is accepted: its readings stay in the record. + * + * Not gated on the instance-wide API switch: no credential is created or + * extended, and the readings already in the record are what it governs. + */ +export const PATCH = apiHandler( + async (request: Request, { params }: RouteParams) => { + const { user } = await requireCookieAuth(); + annotate({ action: { name: "tokens.measurements.mirror.update" } }); + + const rl = await checkRateLimit( + `tokens:mirror:${user.id}`, + MIRROR_RATE_LIMIT_MAX, + MIRROR_RATE_LIMIT_WINDOW_MS, + ); + if (!rl.allowed) { + return apiError("Too many changes, try again later", 429); + } + + const parsed = await parseJsonBody(request, updateMeasurementTokenSchema, { + maxBytes: 4 * 1024, + }); + if (!parsed.ok) return parsed.error; + + const { id } = await params; + const outcome = await setMeasurementTokenMirror({ + userId: user.id, + tokenId: id, + mirrorToAppleHealth: parsed.data.mirrorToAppleHealth, + ipAddress: getClientIp(request), + }); + if (outcome.kind === "not_found") { + return apiError("Token not found", 404); + } + if (outcome.kind === "not_measurement_token") { + return apiError("Only a measurement token has this setting", 422, { + errorCode: "tokens.mirror.not_measurement_token", + }); + } + return apiSuccess({ id, mirrorToAppleHealth: outcome.mirrorToAppleHealth }); + }, +); + +/** Changes per account per window: a settings switch, not an API. */ +const MIRROR_RATE_LIMIT_MAX = 30; +const MIRROR_RATE_LIMIT_WINDOW_MS = 60 * 1000; diff --git a/src/app/api/tokens/measurements/__tests__/route.test.ts b/src/app/api/tokens/measurements/__tests__/route.test.ts index 3a584dd17..29beb8428 100644 --- a/src/app/api/tokens/measurements/__tests__/route.test.ts +++ b/src/app/api/tokens/measurements/__tests__/route.test.ts @@ -39,10 +39,10 @@ vi.mock("@/lib/logging/context", () => ({ })); vi.mock("@/lib/db", () => ({ - prisma: { apiToken: { count: vi.fn() } }, + prisma: { apiToken: { count: vi.fn() }, measurement: { count: vi.fn() } }, })); -import { POST } from "../route"; +import { GET, POST } from "../route"; import { assertRecentCookieProof, requireCookieAuth } from "@/lib/api-handler"; import { issueApiToken } from "@/lib/auth/issue-token"; import { isApiGloballyEnabled } from "@/lib/app-settings"; @@ -147,6 +147,27 @@ describe("what it mints", () => { ); }); + it("leaves the write-back off unless asked, and refuses a non-boolean", async () => { + const mint = (body: unknown) => + POST(req(body) as Parameters[0]); + await mint({ name: "Scale" }); + expect(vi.mocked(issueApiToken).mock.calls[0][0].mirrorToAppleHealth).toBe( + false, + ); + await mint({ name: "Scale", mirrorToAppleHealth: true }); + await mint({ name: "Scale", mirrorToAppleHealth: "yes" }); + expect(issueApiToken).toHaveBeenCalledTimes(2); + expect(vi.mocked(issueApiToken).mock.calls[1][0].mirrorToAppleHealth).toBe( + true, + ); + expect(auditLog).toHaveBeenLastCalledWith( + "tokens.measurements.create", + expect.objectContaining({ + details: expect.objectContaining({ mirrorToAppleHealth: true }), + }), + ); + }); + it("counts only live tokens carrying this scope", async () => { // The whole shape of the query, asserted rather than assumed. Counting // every row would let the `["*"]` tokens a login mints consume the budget @@ -239,3 +260,20 @@ describe("when it refuses", () => { ]); }); }); + +describe("the readings no token reaches", () => { + it("counts the caller's live EXTERNAL readings without a token", async () => { + vi.mocked(prisma.measurement.count).mockResolvedValue(4); + const res = await GET(); + expect(res.status).toBe(200); + expect((await res.json()).data).toEqual({ unattributedReadings: 4 }); + expect(prisma.measurement.count).toHaveBeenCalledWith({ + where: { + userId: USER.id, + source: "EXTERNAL", + ingestTokenId: null, + deletedAt: null, + }, + }); + }); +}); diff --git a/src/app/api/tokens/measurements/route.ts b/src/app/api/tokens/measurements/route.ts index 600b968db..2036ec183 100644 --- a/src/app/api/tokens/measurements/route.ts +++ b/src/app/api/tokens/measurements/route.ts @@ -21,8 +21,8 @@ * operate should not include making more credentials. * * No listing leg. `/api/tokens` already returns every token with its - * `permissions`, so the settings card filters that one query rather than - * duplicating it — the MCP module needs its own list only because it must hide + * `permissions` (and a measurement token's Apple Health setting), so the + * settings card filters that one query rather than duplicating it — the MCP module needs its own list only because it must hide * transient OAuth access rows, and there is no such row here. No revoke-all * either: `DELETE /api/tokens/[id]` is per-row and the card shows the rows. The * per-medication toggle's bulk revoke exists because that surface is a boolean @@ -44,6 +44,7 @@ import { issueApiToken } from "@/lib/auth/issue-token"; import { isApiGloballyEnabled } from "@/lib/app-settings"; import { checkRateLimit } from "@/lib/rate-limit"; import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes"; +import { EXTERNAL_SOURCE } from "@/lib/measurements/external-source"; import { createMeasurementTokenSchema } from "@/lib/validations/tokens"; /** @@ -58,6 +59,28 @@ import { createMeasurementTokenSchema } from "@/lib/validations/tokens"; */ const DEFAULT_EXPIRY_DAYS = 365; +/** + * What the token list cannot say: how many of the account's EXTERNAL readings + * belong to no token (#1272). They were sent before readings were linked to + * the token that carried them, by an account holding more than one token, so + * no token's Apple Health setting reaches them and they stay out of Apple + * Health. The settings card shows a notice when the count is not zero. + */ +export const GET = apiHandler(async () => { + const { user } = await requireCookieAuth(); + annotate({ action: { name: "tokens.measurements.summary" } }); + const unattributedReadings = await prisma.measurement.count({ + where: { + userId: user.id, + source: EXTERNAL_SOURCE, + ingestTokenId: null, + deletedAt: null, + }, + }); + annotate({ meta: { unattributed_readings: unattributedReadings } }); + return apiSuccess({ unattributedReadings }); +}); + /** Mints per user per minute. A credential mint is worth its own bucket. */ const MINT_RATE_LIMIT_MAX = 10; const MINT_RATE_LIMIT_WINDOW_MS = 60 * 1000; @@ -177,12 +200,17 @@ export const POST = apiHandler(async (request: NextRequest) => { // which is why the unit suite asserts this array and not merely the 201. permissions: [MEASUREMENTS_WRITE_SCOPE], expiresInDays: parsed.data.expiresInDays ?? DEFAULT_EXPIRY_DAYS, + mirrorToAppleHealth: parsed.data.mirrorToAppleHealth === true, }); await auditLog("tokens.measurements.create", { userId: user.id, ipAddress: getClientIp(request), - details: { tokenId: issued.tokenId, scope: MEASUREMENTS_WRITE_SCOPE }, + details: { + tokenId: issued.tokenId, + scope: MEASUREMENTS_WRITE_SCOPE, + mirrorToAppleHealth: parsed.data.mirrorToAppleHealth === true, + }, }); // The raw token, once. It is stored as an HMAC and no path re-reveals it. diff --git a/src/app/api/tokens/route.ts b/src/app/api/tokens/route.ts index 0a653efb0..71d44c559 100644 --- a/src/app/api/tokens/route.ts +++ b/src/app/api/tokens/route.ts @@ -46,6 +46,9 @@ export const GET = apiHandler(async () => { expiresAt: true, createdAt: true, revoked: true, + // A measurement token's Apple Health write-back (#1272); false for + // every other token. + mirrorToAppleHealth: true, }, orderBy: { createdAt: "desc" }, }); diff --git a/src/lib/auth/issue-token.ts b/src/lib/auth/issue-token.ts index 0939aaec2..90e878e4e 100644 --- a/src/lib/auth/issue-token.ts +++ b/src/lib/auth/issue-token.ts @@ -36,6 +36,8 @@ export interface IssueTokenOptions { * issued and the connector token list can exclude the transient access rows. */ mcpConnectionId?: string; + /** A measurement token's Apple Health write-back (#1272). Off by default. */ + mirrorToAppleHealth?: boolean; } /** @@ -64,6 +66,9 @@ export async function issueApiToken( ...(opts.mcpConnectionId ? { mcpConnectionId: opts.mcpConnectionId } : {}), + ...(opts.mirrorToAppleHealth === true + ? { mirrorToAppleHealth: true } + : {}), }, select: { id: true }, }); diff --git a/src/lib/measurements/token-mirror.ts b/src/lib/measurements/token-mirror.ts new file mode 100644 index 000000000..e590adf59 --- /dev/null +++ b/src/lib/measurements/token-mirror.ts @@ -0,0 +1,62 @@ +/** + * Switching a measurement token's Apple Health write-back (#1272), for + * `PATCH /api/tokens/{id}`. + * + * One statement on the token row. The readings the token carried are not + * touched: their `updatedAt` and `syncVersion` stay as they are, so nothing + * keyed on them (the rollup freshness probe, personal-record detection, the + * status re-warm) sees a change that is not one. The rows reach a paired + * client again through the `measurementMirror` domain of `/api/sync/changes`, + * keyed on the `mirrorChangedAt` this stamps. + */ +import { prisma } from "@/lib/db"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes"; + +/** A token minted by `POST /api/tokens/measurements`, and nothing else. */ +export function isMeasurementToken(permissions: readonly string[]): boolean { + return ( + permissions.length === 1 && permissions[0] === MEASUREMENTS_WRITE_SCOPE + ); +} + +export type TokenMirrorOutcome = + | { kind: "not_found" } + | { kind: "not_measurement_token" } + | { kind: "set"; mirrorToAppleHealth: boolean }; + +export async function setMeasurementTokenMirror(input: { + userId: string; + tokenId: string; + mirrorToAppleHealth: boolean; + ipAddress: string | null; +}): Promise { + const { userId, tokenId, mirrorToAppleHealth } = input; + const token = await prisma.apiToken.findUnique({ + where: { id: tokenId }, + select: { userId: true, permissions: true, mirrorToAppleHealth: true }, + }); + // Another account's token is answered as missing, like the revoke. + if (!token || token.userId !== userId) return { kind: "not_found" }; + // A revoked token is accepted: its readings stay in the record, and + // whether they are mirrored is still the person's choice. + if (!isMeasurementToken(token.permissions)) { + return { kind: "not_measurement_token" }; + } + + const changed = token.mirrorToAppleHealth !== mirrorToAppleHealth; + if (changed) { + await prisma.apiToken.update({ + where: { id: tokenId }, + data: { mirrorToAppleHealth, mirrorChangedAt: new Date() }, + }); + await auditLog("tokens.measurements.mirror.update", { + userId, + ipAddress: input.ipAddress, + details: { tokenId, mirrorToAppleHealth }, + }); + } + annotate({ meta: { mirror_to_apple_health: mirrorToAppleHealth, changed } }); + return { kind: "set", mirrorToAppleHealth }; +} diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index b91330fb2..fba66f494 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -450,6 +450,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> = tokens: [ "tokens.documents.ceiling_reached", "tokens.measurements.ceiling_reached", + "tokens.mirror.not_measurement_token", "tokens.workouts.ceiling_reached", ], vaccination: [ diff --git a/src/lib/validations/tokens.ts b/src/lib/validations/tokens.ts index d0fcca994..99926eb1d 100644 --- a/src/lib/validations/tokens.ts +++ b/src/lib/validations/tokens.ts @@ -19,6 +19,21 @@ import { z } from "zod/v4"; export const createMeasurementTokenSchema = z.object({ name: z.string().min(1, "Name required").max(100), expiresInDays: z.number().int().min(1).max(365).optional(), + /** + * Whether the HealthLog app writes this token's readings into Apple Health + * (#1272). Off unless the person turns it on; changeable later with + * `PATCH /api/tokens/{id}`. + */ + mirrorToAppleHealth: z.boolean().optional(), +}); + +/** + * Body of `PATCH /api/tokens/{id}`: the one setting a measurement token has. + * Strict, so a key this route does not understand (a `permissions`, a + * `name`) is refused rather than ignored. + */ +export const updateMeasurementTokenSchema = z.strictObject({ + mirrorToAppleHealth: z.boolean(), }); export type CreateMeasurementTokenInput = z.infer< diff --git a/tests/integration/apple-health-mirror.test.ts b/tests/integration/apple-health-mirror.test.ts new file mode 100644 index 000000000..fb62b59d3 --- /dev/null +++ b/tests/integration/apple-health-mirror.test.ts @@ -0,0 +1,351 @@ +/** + * Per-token Apple Health write-back (#1272), end to end against Postgres. + * + * A reading a `measurements:write` token carries keeps its `EXTERNAL` source + * and is linked to the token. Every measurement row answers a resolved + * `appleHealthMirror`: WITHINGS, IMPORT and MANUAL true, EXTERNAL whatever the + * person chose for the token, everything else false. Switching the setting is + * one statement on the token row: the readings themselves are not touched + * (no `updatedAt` or `syncVersion` bump, so nothing keyed on them recomputes), + * and the `measurementMirror` domain of `/api/sync/changes` re-delivers the + * token's rows with the new value. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import { + GET as listMeasurements, + POST as createMeasurement, +} from "@/app/api/measurements/route"; +import { POST as batchMeasurements } from "@/app/api/measurements/batch/route"; +import { GET as readMeasurement } from "@/app/api/measurements/[id]/route"; +import { GET as syncChanges } from "@/app/api/sync/changes/route"; +import { + PATCH as patchToken, + DELETE as revokeToken, +} from "@/app/api/tokens/[id]/route"; +import { GET as listTokens } from "@/app/api/tokens/route"; +import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes"; +import { MEASUREMENT_SOURCES } from "@/lib/measurements/sources"; + +import { callRoute, type RouteAuth } from "../kit/call-route"; +import { + makeMeasurement, + makeSession, + makeToken, + makeUser, + type BearerFixture, +} from "../kit/factories"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const prisma = getPrismaClient(); + +let userId: string; +let owner: RouteAuth; +let scale: BearerFixture; + +beforeEach(async () => { + await truncateAllTables(prisma); + const user = await makeUser({ timezone: "UTC" }); + userId = user.id; + owner = await makeSession(user); + scale = await makeToken(user, { + permissions: [MEASUREMENTS_WRITE_SCOPE], + name: "Scale", + }); +}); + +async function postReading( + value: number, + at: string, + headers?: Record, +) { + return callRoute<{ id: string }>(createMeasurement, { + method: "POST", + route: "/api/measurements", + body: { type: "WEIGHT", value, unit: "kg", measuredAt: at }, + auth: scale, + headers, + }); +} + +async function list(query: Record = {}) { + const res = await callRoute<{ + measurements: Array<{ + id: string; + source: string; + appleHealthMirror: boolean; + }>; + }>(listMeasurements, { + method: "GET", + route: "/api/measurements", + query: { limit: 50, ...query }, + auth: owner, + }); + expect(res.status).toBe(200); + return res.data.measurements; +} + +async function setMirror(id: string, on: boolean, auth: RouteAuth = owner) { + return callRoute<{ id: string; mirrorToAppleHealth: boolean }>(patchToken, { + method: "PATCH", + route: "/api/tokens/{id}", + params: { id }, + body: { mirrorToAppleHealth: on }, + auth, + }); +} + +describe("a token's readings and the resolved write-back", () => { + it("links the reading to its token and resolves each source", async () => { + const res = await postReading(81.2, "2026-10-14T06:30:00.000Z"); + expect(res.status).toBe(201); + const stored = await prisma.measurement.findFirstOrThrow({ + where: { userId, source: "EXTERNAL" }, + select: { ingestTokenId: true }, + }); + expect(stored.ingestTokenId).toBe(scale.tokenId); + + await makeMeasurement( + { id: userId }, + { + source: "MANUAL", + measuredAt: new Date("2026-10-13T06:30:00.000Z"), + }, + ); + await makeMeasurement( + { id: userId }, + { + source: "APPLE_HEALTH", + measuredAt: new Date("2026-10-12T06:30:00.000Z"), + externalId: "hk-1", + }, + ); + + const rows = await list({ type: "WEIGHT" }); + const bySource = Object.fromEntries( + rows.map((r) => [r.source, r.appleHealthMirror]), + ); + expect(bySource).toEqual({ + EXTERNAL: false, + MANUAL: true, + APPLE_HEALTH: false, + }); + // The token never reaches the wire. + for (const row of rows) { + expect(row).not.toHaveProperty("ingestTokenId"); + expect(row).not.toHaveProperty("ingestToken"); + } + }); + + it("links every row of a token batch", async () => { + const res = await callRoute(batchMeasurements, { + method: "POST", + route: "/api/measurements/batch", + body: { + entries: [ + { + hkIdentifier: "HKQuantityTypeIdentifierBodyMass", + value: 80.1, + unit: "kg", + startDate: "2026-10-14T06:30:00.000Z", + endDate: "2026-10-14T06:30:00.000Z", + externalId: "scale-1", + }, + ], + }, + auth: scale, + }); + expect(res.status).toBe(200); + const rows = await prisma.measurement.findMany({ + where: { userId }, + select: { source: true, ingestTokenId: true }, + }); + expect(rows).toEqual([ + { source: "EXTERNAL", ingestTokenId: scale.tokenId }, + ]); + }); +}); + +describe("the list filter", () => { + it("returns exactly the rows that resolve true, for every source", async () => { + const on = await makeToken( + { id: userId }, + { permissions: [MEASUREMENTS_WRITE_SCOPE], name: "On" }, + ); + await prisma.apiToken.update({ + where: { id: on.tokenId }, + data: { mirrorToAppleHealth: true }, + }); + let minute = 0; + for (const source of MEASUREMENT_SOURCES) { + for (const ingestTokenId of [null, scale.tokenId, on.tokenId]) { + minute += 1; + await makeMeasurement( + { id: userId }, + { + source, + ingestTokenId, + measuredAt: new Date(Date.UTC(2026, 9, 1, 6, minute)), + externalId: `row-${minute}`, + }, + ); + } + } + const all = await list({ limit: 500 }); + expect(all).toHaveLength(MEASUREMENT_SOURCES.length * 3); + const expected = all + .filter((r) => r.appleHealthMirror) + .map((r) => r.id) + .sort(); + expect(expected.length).toBeGreaterThan(0); + const filtered = await list({ limit: 500, appleHealthMirror: "true" }); + expect(filtered.map((r) => r.id).sort()).toEqual(expected); + }); + + it.each([ + { groupBy: "day" }, + { dayKey: "2026-10-14" }, + { aggregate: "daily" }, + { type: "SLEEP_DURATION" }, + ])("is refused beside %j", async (mode) => { + const res = await callRoute(listMeasurements, { + method: "GET", + route: "/api/measurements", + query: { type: "WEIGHT", ...mode, appleHealthMirror: "true" }, + auth: owner, + }); + expect(res.status).toBe(422); + }); +}); + +describe("switching the setting", () => { + it("turns the token's rows true everywhere, and leaves the rows themselves alone", async () => { + const created = await postReading(81.2, "2026-10-14T06:30:00.000Z"); + const id = created.data.id; + const before = await prisma.measurement.findUniqueOrThrow({ + where: { id }, + select: { updatedAt: true, syncVersion: true }, + }); + const first = await callRoute<{ cursor: string }>(syncChanges, { + method: "GET", + route: "/api/sync/changes", + auth: owner, + }); + + const res = await setMirror(scale.tokenId, true); + expect(res.status).toBe(200); + expect(res.data).toEqual({ id: scale.tokenId, mirrorToAppleHealth: true }); + + const after = await prisma.measurement.findUniqueOrThrow({ + where: { id }, + select: { updatedAt: true, syncVersion: true }, + }); + expect(after).toEqual(before); + + expect((await list())[0].appleHealthMirror).toBe(true); + expect( + (await list({ appleHealthMirror: "true" })).map((r) => r.id), + ).toEqual([id]); + const detail = await callRoute<{ appleHealthMirror: boolean }>( + readMeasurement, + { + method: "GET", + route: "/api/measurements/{id}", + params: { id }, + auth: owner, + }, + ); + expect(detail.data.appleHealthMirror).toBe(true); + + const next = await callRoute<{ + changes: { + measurements: { upserts: unknown[] }; + measurementMirror: { + upserts: Array<{ id: string; appleHealthMirror: boolean }>; + }; + }; + }>(syncChanges, { + method: "GET", + route: "/api/sync/changes", + query: { cursor: first.data.cursor }, + auth: owner, + }); + expect(next.data.changes.measurements.upserts).toEqual([]); + expect(next.data.changes.measurementMirror.upserts).toEqual([ + { id, appleHealthMirror: true }, + ]); + + const audit = await prisma.auditLog.findMany({ + where: { userId, action: "tokens.measurements.mirror.update" }, + select: { details: true }, + }); + expect(audit).toHaveLength(1); + expect(JSON.parse(audit[0].details ?? "{}")).toMatchObject({ + tokenId: scale.tokenId, + mirrorToAppleHealth: true, + }); + }); + + it("keeps a revoked token's choice, and shows it on the token list", async () => { + await postReading(81.2, "2026-10-14T06:30:00.000Z"); + await setMirror(scale.tokenId, true); + const revoked = await callRoute(revokeToken, { + method: "DELETE", + route: "/api/tokens/{id}", + params: { id: scale.tokenId }, + auth: owner, + }); + expect(revoked.status).toBe(200); + expect((await list())[0].appleHealthMirror).toBe(true); + + // A revoked token can still be switched. + expect((await setMirror(scale.tokenId, false)).status).toBe(200); + expect((await list())[0].appleHealthMirror).toBe(false); + + const tokens = await callRoute< + Array<{ id: string; mirrorToAppleHealth: boolean }> + >(listTokens, { method: "GET", route: "/api/tokens", auth: owner }); + expect(tokens.data.find((t) => t.id === scale.tokenId)).toMatchObject({ + mirrorToAppleHealth: false, + }); + }); + + it("answers a retried create from its idempotency record, unchanged", async () => { + const headers = { "idempotency-key": "scale-reading-1" }; + const first = await postReading(81.2, "2026-10-14T06:30:00.000Z", headers); + await setMirror(scale.tokenId, true); + const replay = await postReading(81.2, "2026-10-14T06:30:00.000Z", headers); + expect(replay.status).toBe(first.status); + expect(replay.body).toEqual(first.body); + expect( + await prisma.measurement.count({ where: { userId, source: "EXTERNAL" } }), + ).toBe(1); + }); +}); + +describe("what the switch refuses", () => { + it("refuses a token that is not a measurement token", async () => { + const wildcard = await makeToken({ id: userId }, { permissions: ["*"] }); + const res = await setMirror(wildcard.tokenId, true); + expect(res.status).toBe(422); + expect(res.meta?.errorCode).toBe("tokens.mirror.not_measurement_token"); + }); + + it("answers another account's token as missing", async () => { + const stranger = await makeUser({ timezone: "UTC" }); + const theirs = await makeToken(stranger, { + permissions: [MEASUREMENTS_WRITE_SCOPE], + }); + expect((await setMirror(theirs.tokenId, true)).status).toBe(404); + }); + + it("refuses a Bearer caller, the token's own included", async () => { + const res = await setMirror(scale.tokenId, true, scale); + expect([401, 403]).toContain(res.status); + const stored = await prisma.apiToken.findUniqueOrThrow({ + where: { id: scale.tokenId }, + select: { mirrorToAppleHealth: true }, + }); + expect(stored.mirrorToAppleHealth).toBe(false); + }); +}); diff --git a/tests/integration/routes/kit-contract.test.ts b/tests/integration/routes/kit-contract.test.ts index d2e28b609..c0866f4c6 100644 --- a/tests/integration/routes/kit-contract.test.ts +++ b/tests/integration/routes/kit-contract.test.ts @@ -27,6 +27,7 @@ const token = { expiresAt: null, createdAt: "2026-01-01T00:00:00.000Z", revoked: false, + mirrorToAppleHealth: false, }; function answering(status: number, body: unknown) { diff --git a/tests/integration/routes/parity/__snapshots__/tokens-mirror.01-summary.json.snap b/tests/integration/routes/parity/__snapshots__/tokens-mirror.01-summary.json.snap new file mode 100644 index 000000000..64122a4e9 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/tokens-mirror.01-summary.json.snap @@ -0,0 +1,68 @@ +{ + "status": 200, + "body": { + "data": { + "unattributedReadings": 1 + }, + "error": null + }, + "events": [ + { + "status": 200, + "action": { + "name": "tokens.measurements.summary" + }, + "meta": { + "unattributed_readings": 1 + }, + "warnings": null, + "error": null + } + ], + "stored": { + "tokens": [ + { + "name": "Connector", + "permissions": [ + "health:read" + ], + "revoked": false, + "mirrorToAppleHealth": false, + "mirrorChangedAt": null + }, + { + "name": "Phone", + "permissions": [ + "*" + ], + "revoked": false, + "mirrorToAppleHealth": false, + "mirrorChangedAt": null + }, + { + "name": "Scale", + "permissions": [ + "measurements:write" + ], + "revoked": false, + "mirrorToAppleHealth": false, + "mirrorChangedAt": null + } + ], + "audit": [], + "readings": [ + { + "source": "EXTERNAL", + "ingestTokenId": null, + "stamp": "2026-10-10T08:00:00.000Z", + "version": 1 + }, + { + "source": "EXTERNAL", + "ingestTokenId": "", + "stamp": "2026-10-10T08:00:00.000Z", + "version": 1 + } + ] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/tokens-mirror.02-on.json.snap b/tests/integration/routes/parity/__snapshots__/tokens-mirror.02-on.json.snap new file mode 100644 index 000000000..c4acf2966 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/tokens-mirror.02-on.json.snap @@ -0,0 +1,78 @@ +{ + "status": 200, + "body": { + "data": { + "id": "", + "mirrorToAppleHealth": true + }, + "error": null + }, + "events": [ + { + "status": 200, + "action": { + "name": "tokens.measurements.mirror.update" + }, + "meta": { + "mirror_to_apple_health": true, + "changed": true + }, + "warnings": null, + "error": null + } + ], + "stored": { + "tokens": [ + { + "name": "Connector", + "permissions": [ + "health:read" + ], + "revoked": false, + "mirrorToAppleHealth": false, + "mirrorChangedAt": null + }, + { + "name": "Phone", + "permissions": [ + "*" + ], + "revoked": false, + "mirrorToAppleHealth": false, + "mirrorChangedAt": null + }, + { + "name": "Scale", + "permissions": [ + "measurements:write" + ], + "revoked": false, + "mirrorToAppleHealth": true, + "mirrorChangedAt": "" + } + ], + "audit": [ + { + "action": "tokens.measurements.mirror.update", + "details": { + "tokenId": "", + "mirrorToAppleHealth": true + } + } + ], + "readings": [ + { + "source": "EXTERNAL", + "ingestTokenId": null, + "stamp": "2026-10-10T08:00:00.000Z", + "version": 1 + }, + { + "source": "EXTERNAL", + "ingestTokenId": "", + "stamp": "2026-10-10T08:00:00.000Z", + "version": 1 + } + ] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/tokens-mirror.03-on-again.json.snap b/tests/integration/routes/parity/__snapshots__/tokens-mirror.03-on-again.json.snap new file mode 100644 index 000000000..9786092b0 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/tokens-mirror.03-on-again.json.snap @@ -0,0 +1,78 @@ +{ + "status": 200, + "body": { + "data": { + "id": "", + "mirrorToAppleHealth": true + }, + "error": null + }, + "events": [ + { + "status": 200, + "action": { + "name": "tokens.measurements.mirror.update" + }, + "meta": { + "mirror_to_apple_health": true, + "changed": false + }, + "warnings": null, + "error": null + } + ], + "stored": { + "tokens": [ + { + "name": "Connector", + "permissions": [ + "health:read" + ], + "revoked": false, + "mirrorToAppleHealth": false, + "mirrorChangedAt": null + }, + { + "name": "Phone", + "permissions": [ + "*" + ], + "revoked": false, + "mirrorToAppleHealth": false, + "mirrorChangedAt": null + }, + { + "name": "Scale", + "permissions": [ + "measurements:write" + ], + "revoked": false, + "mirrorToAppleHealth": true, + "mirrorChangedAt": "" + } + ], + "audit": [ + { + "action": "tokens.measurements.mirror.update", + "details": { + "tokenId": "", + "mirrorToAppleHealth": true + } + } + ], + "readings": [ + { + "source": "EXTERNAL", + "ingestTokenId": null, + "stamp": "2026-10-10T08:00:00.000Z", + "version": 1 + }, + { + "source": "EXTERNAL", + "ingestTokenId": "", + "stamp": "2026-10-10T08:00:00.000Z", + "version": 1 + } + ] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/tokens-mirror.04-list.json.snap b/tests/integration/routes/parity/__snapshots__/tokens-mirror.04-list.json.snap new file mode 100644 index 000000000..7b1e922ca --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/tokens-mirror.04-list.json.snap @@ -0,0 +1,109 @@ +{ + "status": 200, + "body": { + "data": [ + { + "id": "", + "name": "Scale", + "permissions": [ + "measurements:write" + ], + "lastUsedAt": null, + "expiresAt": "2027-01-13T12:00:00.000Z", + "createdAt": "", + "revoked": false, + "mirrorToAppleHealth": true + }, + { + "id": "", + "name": "Phone", + "permissions": [ + "*" + ], + "lastUsedAt": null, + "expiresAt": "2027-01-13T12:00:00.000Z", + "createdAt": "", + "revoked": false, + "mirrorToAppleHealth": false + }, + { + "id": "", + "name": "Connector", + "permissions": [ + "health:read" + ], + "lastUsedAt": null, + "expiresAt": "2027-01-13T12:00:00.000Z", + "createdAt": "", + "revoked": false, + "mirrorToAppleHealth": false + } + ], + "error": null + }, + "events": [ + { + "status": 200, + "action": { + "name": "tokens.list" + }, + "meta": null, + "warnings": null, + "error": null + } + ], + "stored": { + "tokens": [ + { + "name": "Connector", + "permissions": [ + "health:read" + ], + "revoked": false, + "mirrorToAppleHealth": false, + "mirrorChangedAt": null + }, + { + "name": "Phone", + "permissions": [ + "*" + ], + "revoked": false, + "mirrorToAppleHealth": false, + "mirrorChangedAt": null + }, + { + "name": "Scale", + "permissions": [ + "measurements:write" + ], + "revoked": false, + "mirrorToAppleHealth": true, + "mirrorChangedAt": "" + } + ], + "audit": [ + { + "action": "tokens.measurements.mirror.update", + "details": { + "tokenId": "", + "mirrorToAppleHealth": true + } + } + ], + "readings": [ + { + "source": "EXTERNAL", + "ingestTokenId": null, + "stamp": "2026-10-10T08:00:00.000Z", + "version": 1 + }, + { + "source": "EXTERNAL", + "ingestTokenId": "", + "stamp": "2026-10-10T08:00:00.000Z", + "version": 1 + } + ] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/tokens-mirror.05-off-revoked.json.snap b/tests/integration/routes/parity/__snapshots__/tokens-mirror.05-off-revoked.json.snap new file mode 100644 index 000000000..78d0c3a5c --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/tokens-mirror.05-off-revoked.json.snap @@ -0,0 +1,85 @@ +{ + "status": 200, + "body": { + "data": { + "id": "", + "mirrorToAppleHealth": false + }, + "error": null + }, + "events": [ + { + "status": 200, + "action": { + "name": "tokens.measurements.mirror.update" + }, + "meta": { + "mirror_to_apple_health": false, + "changed": true + }, + "warnings": null, + "error": null + } + ], + "stored": { + "tokens": [ + { + "name": "Connector", + "permissions": [ + "health:read" + ], + "revoked": false, + "mirrorToAppleHealth": false, + "mirrorChangedAt": null + }, + { + "name": "Phone", + "permissions": [ + "*" + ], + "revoked": false, + "mirrorToAppleHealth": false, + "mirrorChangedAt": null + }, + { + "name": "Scale", + "permissions": [ + "measurements:write" + ], + "revoked": true, + "mirrorToAppleHealth": false, + "mirrorChangedAt": "" + } + ], + "audit": [ + { + "action": "tokens.measurements.mirror.update", + "details": { + "tokenId": "", + "mirrorToAppleHealth": true + } + }, + { + "action": "tokens.measurements.mirror.update", + "details": { + "tokenId": "", + "mirrorToAppleHealth": false + } + } + ], + "readings": [ + { + "source": "EXTERNAL", + "ingestTokenId": null, + "stamp": "2026-10-10T08:00:00.000Z", + "version": 1 + }, + { + "source": "EXTERNAL", + "ingestTokenId": "", + "stamp": "2026-10-10T08:00:00.000Z", + "version": 1 + } + ] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/tokens-mirror.06-wildcard.json.snap b/tests/integration/routes/parity/__snapshots__/tokens-mirror.06-wildcard.json.snap new file mode 100644 index 000000000..2998d1a45 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/tokens-mirror.06-wildcard.json.snap @@ -0,0 +1,83 @@ +{ + "status": 422, + "body": { + "data": null, + "error": "Only a measurement token has this setting", + "meta": { + "errorCode": "tokens.mirror.not_measurement_token", + "requestId": "" + } + }, + "events": [ + { + "status": 422, + "action": { + "name": "tokens.measurements.mirror.update" + }, + "meta": null, + "warnings": null, + "error": null + } + ], + "stored": { + "tokens": [ + { + "name": "Connector", + "permissions": [ + "health:read" + ], + "revoked": false, + "mirrorToAppleHealth": false, + "mirrorChangedAt": null + }, + { + "name": "Phone", + "permissions": [ + "*" + ], + "revoked": false, + "mirrorToAppleHealth": false, + "mirrorChangedAt": null + }, + { + "name": "Scale", + "permissions": [ + "measurements:write" + ], + "revoked": true, + "mirrorToAppleHealth": false, + "mirrorChangedAt": "" + } + ], + "audit": [ + { + "action": "tokens.measurements.mirror.update", + "details": { + "tokenId": "", + "mirrorToAppleHealth": true + } + }, + { + "action": "tokens.measurements.mirror.update", + "details": { + "tokenId": "", + "mirrorToAppleHealth": false + } + } + ], + "readings": [ + { + "source": "EXTERNAL", + "ingestTokenId": null, + "stamp": "2026-10-10T08:00:00.000Z", + "version": 1 + }, + { + "source": "EXTERNAL", + "ingestTokenId": "", + "stamp": "2026-10-10T08:00:00.000Z", + "version": 1 + } + ] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/tokens-mirror.07-connector.json.snap b/tests/integration/routes/parity/__snapshots__/tokens-mirror.07-connector.json.snap new file mode 100644 index 000000000..2998d1a45 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/tokens-mirror.07-connector.json.snap @@ -0,0 +1,83 @@ +{ + "status": 422, + "body": { + "data": null, + "error": "Only a measurement token has this setting", + "meta": { + "errorCode": "tokens.mirror.not_measurement_token", + "requestId": "" + } + }, + "events": [ + { + "status": 422, + "action": { + "name": "tokens.measurements.mirror.update" + }, + "meta": null, + "warnings": null, + "error": null + } + ], + "stored": { + "tokens": [ + { + "name": "Connector", + "permissions": [ + "health:read" + ], + "revoked": false, + "mirrorToAppleHealth": false, + "mirrorChangedAt": null + }, + { + "name": "Phone", + "permissions": [ + "*" + ], + "revoked": false, + "mirrorToAppleHealth": false, + "mirrorChangedAt": null + }, + { + "name": "Scale", + "permissions": [ + "measurements:write" + ], + "revoked": true, + "mirrorToAppleHealth": false, + "mirrorChangedAt": "" + } + ], + "audit": [ + { + "action": "tokens.measurements.mirror.update", + "details": { + "tokenId": "", + "mirrorToAppleHealth": true + } + }, + { + "action": "tokens.measurements.mirror.update", + "details": { + "tokenId": "", + "mirrorToAppleHealth": false + } + } + ], + "readings": [ + { + "source": "EXTERNAL", + "ingestTokenId": null, + "stamp": "2026-10-10T08:00:00.000Z", + "version": 1 + }, + { + "source": "EXTERNAL", + "ingestTokenId": "", + "stamp": "2026-10-10T08:00:00.000Z", + "version": 1 + } + ] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/tokens-mirror.08-another-account.json.snap b/tests/integration/routes/parity/__snapshots__/tokens-mirror.08-another-account.json.snap new file mode 100644 index 000000000..127519719 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/tokens-mirror.08-another-account.json.snap @@ -0,0 +1,83 @@ +{ + "status": 404, + "body": { + "data": null, + "error": "Token not found", + "meta": { + "errorCode": "http.not_found", + "requestId": "" + } + }, + "events": [ + { + "status": 404, + "action": { + "name": "tokens.measurements.mirror.update" + }, + "meta": null, + "warnings": null, + "error": null + } + ], + "stored": { + "tokens": [ + { + "name": "Connector", + "permissions": [ + "health:read" + ], + "revoked": false, + "mirrorToAppleHealth": false, + "mirrorChangedAt": null + }, + { + "name": "Phone", + "permissions": [ + "*" + ], + "revoked": false, + "mirrorToAppleHealth": false, + "mirrorChangedAt": null + }, + { + "name": "Scale", + "permissions": [ + "measurements:write" + ], + "revoked": true, + "mirrorToAppleHealth": false, + "mirrorChangedAt": "" + } + ], + "audit": [ + { + "action": "tokens.measurements.mirror.update", + "details": { + "tokenId": "", + "mirrorToAppleHealth": true + } + }, + { + "action": "tokens.measurements.mirror.update", + "details": { + "tokenId": "", + "mirrorToAppleHealth": false + } + } + ], + "readings": [ + { + "source": "EXTERNAL", + "ingestTokenId": null, + "stamp": "2026-10-10T08:00:00.000Z", + "version": 1 + }, + { + "source": "EXTERNAL", + "ingestTokenId": "", + "stamp": "2026-10-10T08:00:00.000Z", + "version": 1 + } + ] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/tokens-mirror.09-unknown.json.snap b/tests/integration/routes/parity/__snapshots__/tokens-mirror.09-unknown.json.snap new file mode 100644 index 000000000..127519719 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/tokens-mirror.09-unknown.json.snap @@ -0,0 +1,83 @@ +{ + "status": 404, + "body": { + "data": null, + "error": "Token not found", + "meta": { + "errorCode": "http.not_found", + "requestId": "" + } + }, + "events": [ + { + "status": 404, + "action": { + "name": "tokens.measurements.mirror.update" + }, + "meta": null, + "warnings": null, + "error": null + } + ], + "stored": { + "tokens": [ + { + "name": "Connector", + "permissions": [ + "health:read" + ], + "revoked": false, + "mirrorToAppleHealth": false, + "mirrorChangedAt": null + }, + { + "name": "Phone", + "permissions": [ + "*" + ], + "revoked": false, + "mirrorToAppleHealth": false, + "mirrorChangedAt": null + }, + { + "name": "Scale", + "permissions": [ + "measurements:write" + ], + "revoked": true, + "mirrorToAppleHealth": false, + "mirrorChangedAt": "" + } + ], + "audit": [ + { + "action": "tokens.measurements.mirror.update", + "details": { + "tokenId": "", + "mirrorToAppleHealth": true + } + }, + { + "action": "tokens.measurements.mirror.update", + "details": { + "tokenId": "", + "mirrorToAppleHealth": false + } + } + ], + "readings": [ + { + "source": "EXTERNAL", + "ingestTokenId": null, + "stamp": "2026-10-10T08:00:00.000Z", + "version": 1 + }, + { + "source": "EXTERNAL", + "ingestTokenId": "", + "stamp": "2026-10-10T08:00:00.000Z", + "version": 1 + } + ] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/tokens-mirror.10-invalid-body.json.snap b/tests/integration/routes/parity/__snapshots__/tokens-mirror.10-invalid-body.json.snap new file mode 100644 index 000000000..7fb7648f3 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/tokens-mirror.10-invalid-body.json.snap @@ -0,0 +1,100 @@ +{ + "status": 422, + "body": { + "data": null, + "error": "Validation failed", + "details": { + "issues": [ + { + "path": "mirrorToAppleHealth", + "code": "invalid_type", + "message": "Invalid input: expected boolean, received string" + }, + { + "path": "", + "code": "unrecognized_keys", + "message": "Unrecognized key: \"name\"", + "keys": [ + "name" + ] + } + ] + }, + "meta": { + "errorCode": "http.invalid", + "requestId": "" + } + }, + "events": [ + { + "status": 422, + "action": { + "name": "tokens.measurements.mirror.update" + }, + "meta": null, + "warnings": null, + "error": null + } + ], + "stored": { + "tokens": [ + { + "name": "Connector", + "permissions": [ + "health:read" + ], + "revoked": false, + "mirrorToAppleHealth": false, + "mirrorChangedAt": null + }, + { + "name": "Phone", + "permissions": [ + "*" + ], + "revoked": false, + "mirrorToAppleHealth": false, + "mirrorChangedAt": null + }, + { + "name": "Scale", + "permissions": [ + "measurements:write" + ], + "revoked": true, + "mirrorToAppleHealth": false, + "mirrorChangedAt": "" + } + ], + "audit": [ + { + "action": "tokens.measurements.mirror.update", + "details": { + "tokenId": "", + "mirrorToAppleHealth": true + } + }, + { + "action": "tokens.measurements.mirror.update", + "details": { + "tokenId": "", + "mirrorToAppleHealth": false + } + } + ], + "readings": [ + { + "source": "EXTERNAL", + "ingestTokenId": null, + "stamp": "2026-10-10T08:00:00.000Z", + "version": 1 + }, + { + "source": "EXTERNAL", + "ingestTokenId": "", + "stamp": "2026-10-10T08:00:00.000Z", + "version": 1 + } + ] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/tokens-mirror.11-bearer.json.snap b/tests/integration/routes/parity/__snapshots__/tokens-mirror.11-bearer.json.snap new file mode 100644 index 000000000..29e858126 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/tokens-mirror.11-bearer.json.snap @@ -0,0 +1,84 @@ +{ + "status": 401, + "body": { + "data": null, + "error": "Not authenticated", + "meta": { + "errorCode": "auth.missing", + "requestId": "" + } + }, + "events": [ + { + "status": 401, + "action": null, + "meta": null, + "warnings": null, + "error": { + "type": "HttpError", + "message": "Not authenticated" + } + } + ], + "stored": { + "tokens": [ + { + "name": "Connector", + "permissions": [ + "health:read" + ], + "revoked": false, + "mirrorToAppleHealth": false, + "mirrorChangedAt": null + }, + { + "name": "Phone", + "permissions": [ + "*" + ], + "revoked": false, + "mirrorToAppleHealth": false, + "mirrorChangedAt": null + }, + { + "name": "Scale", + "permissions": [ + "measurements:write" + ], + "revoked": true, + "mirrorToAppleHealth": false, + "mirrorChangedAt": "" + } + ], + "audit": [ + { + "action": "tokens.measurements.mirror.update", + "details": { + "tokenId": "", + "mirrorToAppleHealth": true + } + }, + { + "action": "tokens.measurements.mirror.update", + "details": { + "tokenId": "", + "mirrorToAppleHealth": false + } + } + ], + "readings": [ + { + "source": "EXTERNAL", + "ingestTokenId": null, + "stamp": "2026-10-10T08:00:00.000Z", + "version": 1 + }, + { + "source": "EXTERNAL", + "ingestTokenId": "", + "stamp": "2026-10-10T08:00:00.000Z", + "version": 1 + } + ] + } +} diff --git a/tests/integration/routes/parity/__snapshots__/tokens-mirror.12-summary-bearer.json.snap b/tests/integration/routes/parity/__snapshots__/tokens-mirror.12-summary-bearer.json.snap new file mode 100644 index 000000000..29e858126 --- /dev/null +++ b/tests/integration/routes/parity/__snapshots__/tokens-mirror.12-summary-bearer.json.snap @@ -0,0 +1,84 @@ +{ + "status": 401, + "body": { + "data": null, + "error": "Not authenticated", + "meta": { + "errorCode": "auth.missing", + "requestId": "" + } + }, + "events": [ + { + "status": 401, + "action": null, + "meta": null, + "warnings": null, + "error": { + "type": "HttpError", + "message": "Not authenticated" + } + } + ], + "stored": { + "tokens": [ + { + "name": "Connector", + "permissions": [ + "health:read" + ], + "revoked": false, + "mirrorToAppleHealth": false, + "mirrorChangedAt": null + }, + { + "name": "Phone", + "permissions": [ + "*" + ], + "revoked": false, + "mirrorToAppleHealth": false, + "mirrorChangedAt": null + }, + { + "name": "Scale", + "permissions": [ + "measurements:write" + ], + "revoked": true, + "mirrorToAppleHealth": false, + "mirrorChangedAt": "" + } + ], + "audit": [ + { + "action": "tokens.measurements.mirror.update", + "details": { + "tokenId": "", + "mirrorToAppleHealth": true + } + }, + { + "action": "tokens.measurements.mirror.update", + "details": { + "tokenId": "", + "mirrorToAppleHealth": false + } + } + ], + "readings": [ + { + "source": "EXTERNAL", + "ingestTokenId": null, + "stamp": "2026-10-10T08:00:00.000Z", + "version": 1 + }, + { + "source": "EXTERNAL", + "ingestTokenId": "", + "stamp": "2026-10-10T08:00:00.000Z", + "version": 1 + } + ] + } +} diff --git a/tests/integration/routes/parity/tokens-mirror.parity.test.ts b/tests/integration/routes/parity/tokens-mirror.parity.test.ts new file mode 100644 index 000000000..141c811ec --- /dev/null +++ b/tests/integration/routes/parity/tokens-mirror.parity.test.ts @@ -0,0 +1,238 @@ +/** + * Parity for the Apple Health write-back of measurement tokens (#1272): + * `PATCH /api/tokens/{id}`, the summary `GET /api/tokens/measurements` and + * the token list that carries the setting. + * + * One account walks through the cases the routes tell apart, in order: the + * summary with readings no token reaches, the switch turned on, the same + * value again (nothing written, nothing audited), turned off on a revoked + * token, a wildcard and an MCP token refused, another account's token, an + * unknown id, bodies the schema refuses, and a Bearer caller. After each + * request the snapshot holds the response, the wide event, the token rows, + * the audit rows and the readings' stamps, which must never move. + */ +import { beforeAll, describe, expect, it, vi } from "vitest"; + +import { PATCH, DELETE as revoke } from "@/app/api/tokens/[id]/route"; +import { GET as listTokens } from "@/app/api/tokens/route"; +import { GET as summary } from "@/app/api/tokens/measurements/route"; +import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes"; + +import { callRoute, type RouteAuth } from "../../../kit/call-route"; +import { + makeMeasurement, + makeSession, + makeToken, + makeUser, + type BearerFixture, +} from "../../../kit/factories"; +import { + eventFacts, + expectParity, + stable, + wideEventRecorder, +} from "../../../kit/parity"; +import { getPrismaClient, truncateAllTables } from "../../setup"; + +vi.mock("@/lib/logging/transports", async (importOriginal) => { + const actual = + await importOriginal(); + const { wideEventRecorder: recorder } = await import("../../../kit/parity"); + return { + ...actual, + emitIfSampled: (event: (typeof recorder.events)[number]) => { + recorder.events.push(event); + }, + }; +}); + +const prisma = getPrismaClient(); +const NOW = new Date("2026-10-15T12:00:00.000Z"); + +let userId: string; +let owner: RouteAuth; +let scale: BearerFixture; +let wildcard: BearerFixture; +let connector: BearerFixture; +let theirs: BearerFixture; + +async function stored() { + const [tokens, audit, readings] = await Promise.all([ + prisma.apiToken.findMany({ + where: { userId }, + orderBy: { name: "asc" }, + select: { + name: true, + permissions: true, + revoked: true, + mirrorToAppleHealth: true, + mirrorChangedAt: true, + }, + }), + prisma.auditLog.findMany({ + where: { userId, action: { startsWith: "tokens." } }, + orderBy: { createdAt: "asc" }, + select: { action: true, details: true }, + }), + prisma.measurement.findMany({ + where: { userId }, + orderBy: { measuredAt: "asc" }, + select: { + source: true, + ingestTokenId: true, + updatedAt: true, + syncVersion: true, + }, + }), + ]); + // Renamed so the snapshot keeps the values (`updatedAt` and `syncVersion` + // are blanked as volatile): a switch must never move them. + return { + tokens, + // Parsed, so the token id inside reads as its placeholder. + audit: audit.map((a) => ({ + action: a.action, + details: a.details ? JSON.parse(a.details) : null, + })), + readings: readings.map((r) => ({ + source: r.source, + ingestTokenId: r.ingestTokenId, + stamp: r.updatedAt.toISOString(), + version: r.syncVersion, + })), + }; +} + +async function step( + name: string, + call: + | { kind: "patch"; id: string; body: unknown; auth?: RouteAuth } + | { kind: "summary" | "list"; auth?: RouteAuth }, +): Promise { + wideEventRecorder.take(); + const res = + call.kind === "patch" + ? await callRoute(PATCH, { + method: "PATCH", + route: "/api/tokens/{id}", + params: { id: call.id }, + body: call.body, + auth: call.auth ?? owner, + }) + : await callRoute(call.kind === "summary" ? summary : listTokens, { + method: "GET", + route: + call.kind === "summary" + ? "/api/tokens/measurements" + : "/api/tokens", + auth: call.auth ?? owner, + }); + const events = wideEventRecorder.take(); + expect(events.length).toBeGreaterThan(0); + await expectParity( + `tokens-mirror.${name}`, + stable( + { + status: res.status, + body: res.body, + events: events.map(eventFacts), + stored: await stored(), + }, + { since: NOW, volatileKeys: ["mirrorChangedAt"] }, + ), + ); +} + +beforeAll(async () => { + vi.useFakeTimers({ toFake: ["Date"], now: NOW }); + await truncateAllTables(prisma); + const user = await makeUser({ timezone: "UTC" }); + userId = user.id; + owner = await makeSession(user); + scale = await makeToken(user, { + permissions: [MEASUREMENTS_WRITE_SCOPE], + name: "Scale", + }); + wildcard = await makeToken(user, { permissions: ["*"], name: "Phone" }); + connector = await makeToken(user, { + permissions: ["health:read"], + name: "Connector", + }); + theirs = await makeToken(await makeUser({ timezone: "UTC" }), { + permissions: [MEASUREMENTS_WRITE_SCOPE], + name: "Theirs", + }); + const stamp = new Date("2026-10-10T08:00:00.000Z"); + await makeMeasurement(user, { + source: "EXTERNAL", + ingestTokenId: scale.tokenId, + measuredAt: new Date("2026-10-10T07:00:00.000Z"), + updatedAt: stamp, + }); + await makeMeasurement(user, { + source: "EXTERNAL", + measuredAt: new Date("2026-10-09T07:00:00.000Z"), + updatedAt: stamp, + }); +}); + +describe("the Apple Health write-back of a measurement token", () => { + it("walks the cases in order", async () => { + await step("01-summary", { kind: "summary" }); + await step("02-on", { + kind: "patch", + id: scale.tokenId, + body: { mirrorToAppleHealth: true }, + }); + await step("03-on-again", { + kind: "patch", + id: scale.tokenId, + body: { mirrorToAppleHealth: true }, + }); + await step("04-list", { kind: "list" }); + const revoked = await callRoute(revoke, { + method: "DELETE", + route: "/api/tokens/{id}", + params: { id: scale.tokenId }, + auth: owner, + }); + expect(revoked.status).toBe(200); + await step("05-off-revoked", { + kind: "patch", + id: scale.tokenId, + body: { mirrorToAppleHealth: false }, + }); + await step("06-wildcard", { + kind: "patch", + id: wildcard.tokenId, + body: { mirrorToAppleHealth: true }, + }); + await step("07-connector", { + kind: "patch", + id: connector.tokenId, + body: { mirrorToAppleHealth: true }, + }); + await step("08-another-account", { + kind: "patch", + id: theirs.tokenId, + body: { mirrorToAppleHealth: true }, + }); + await step("09-unknown", { + kind: "patch", + id: "missing-token", + body: { mirrorToAppleHealth: true }, + }); + await step("10-invalid-body", { + kind: "patch", + id: scale.tokenId, + body: { mirrorToAppleHealth: "yes", name: "Renamed" }, + }); + await step("11-bearer", { + kind: "patch", + id: scale.tokenId, + body: { mirrorToAppleHealth: true }, + auth: wildcard, + }); + await step("12-summary-bearer", { kind: "summary", auth: wildcard }); + }); +}); From d6774b770db316b8dab863565286d59aa8b70aa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 19:28:05 +0200 Subject: [PATCH 09/10] feat(openapi): publish the Apple Health write-back contract Declare appleHealthMirror on measurement rows and sync upserts, the list filter, the measurementMirror sync domain, the token setting on the list, the mint and the new PATCH, and the unattributed-readings summary. --- docs/api/README.md | 6 +- docs/api/openapi.yaml | 237 +++++++++++++++++++++++-- src/lib/openapi/routes/auth.ts | 86 ++++++++- src/lib/openapi/routes/measurements.ts | 6 + src/lib/openapi/routes/sync.ts | 37 +++- 5 files changed, 354 insertions(+), 18 deletions(-) diff --git a/docs/api/README.md b/docs/api/README.md index 1d4ddd005..507e31faf 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -66,7 +66,11 @@ before treating a warning as a regression. `health:write`), audience-bound to `/mcp` and refused on REST writes; - `POST /api/tokens/measurements` mints `measurements:write`, accepted by `POST /api/measurements` and `POST /api/measurements/batch` only, on the - holder's own record only; + holder's own record only. The readings it writes carry `source: EXTERNAL` + and are linked to the token; whether the HealthLog app writes them into + Apple Health is the token's `mirrorToAppleHealth` setting (off by + default, changed with `PATCH /api/tokens/{id}`), resolved onto every + measurement row as `appleHealthMirror`; - `POST /api/tokens/workouts` mints `workouts:write`, accepted by `POST /api/workouts/batch` only, on the holder's own record only; the workouts it writes carry `source: EXTERNAL`; diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index ee9bd53e2..563d48eb1 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -3372,6 +3372,25 @@ paths: "422": *a4 "429": *a5 /api/tokens/measurements: + get: + tags: + - Auth + summary: Readings no measurement token reaches + description: Counts the caller's live EXTERNAL readings that are linked to no token. Readings are linked to the token + that sent them since v1.46; older ones were linked by a one-time pass only when the account held exactly one + measurement token, so the rest belong to none and no token's Apple Health setting reaches them. Cookie session + only. + responses: + "200": + description: The count. + content: + application/json: + schema: + $ref: "#/components/schemas/MeasurementTokenSummaryEnvelope" + "401": *a1 + "403": *a9 + "422": *a4 + "429": *a5 post: tags: - Auth @@ -3690,6 +3709,53 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 + patch: + tags: + - Auth + summary: Change a measurement token's Apple Health setting + description: "Sets whether the HealthLog app writes the readings this measurement token sent into Apple Health. The + readings keep their `EXTERNAL` source and are not rewritten: every measurement row resolves `appleHealthMirror` + from the token at read time, and a paired client learns of the change through the `measurementMirror` domain of + `GET /api/sync/changes`. Turning it off stops further writes; samples already in Apple Health stay. A revoked + token is accepted, since its readings stay in the record. Cookie session only, the caller's own tokens only + (another account's answers 404). Audited as `tokens.measurements.mirror.update` when the value changes. 30 + changes per account per minute. Not gated on the instance-wide API switch: no credential is created or + extended." + parameters: + - in: path + name: id + schema: + type: string + required: true + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateMeasurementTokenRequest" + responses: + "200": + description: The setting as it now stands. + content: + application/json: + schema: + $ref: "#/components/schemas/UpdateMeasurementTokenEnvelope" + "401": *a1 + "403": *a9 + "404": + description: Token not found or not owned by the caller. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": + description: "Validation failed, or the token is not a measurement token (`meta.errorCode` = + `tokens.mirror.not_measurement_token`): a wildcard, connector or other narrow token has no such setting." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "429": *a5 /api/auth/register: post: security: [] @@ -4224,10 +4290,11 @@ paths: - Sync summary: Multi-domain delta feed description: "Incremental catch-up after the first-pair backfill (never a replacement for it). Pages over one opaque - keyset cursor spanning five domains — measurements, mood, medication intakes, cycle day logs and cycles. Each - domain carries `tombstones` (soft-deleted rows) and `upserts` (live rows); apply tombstones before upserts - within a domain. Tombstone identity is `externalId` for measurements and the server `id` everywhere else. - `cursorExpired: true` forces a clean re-init and the page is empty in every domain." + keyset cursor spanning six domains — measurements, mood, medication intakes, cycle day logs, cycles, and + `measurementMirror` (the Apple Health write-back of readings whose token setting changed). Each domain carries + `tombstones` (soft-deleted rows) and `upserts` (live rows); apply tombstones before upserts within a domain. + Tombstone identity is `externalId` for measurements and the server `id` everywhere else. `cursorExpired: true` + forces a clean re-init and the page is empty in every domain." parameters: - in: query name: cursor @@ -4554,6 +4621,12 @@ paths: - STRAVA - EXTERNAL - HEALTH_CONNECT + - in: query + name: appleHealthMirror + schema: + type: string + enum: + - "true" - in: query name: valueMin schema: @@ -28142,12 +28215,15 @@ components: type: integer minimum: 1 maximum: 365 + mirrorToAppleHealth: + type: boolean required: - name description: "Mint a measurement-ingest Bearer. There is no `scope` field: this endpoint mints one shape and the permission array is a literal, so no request can widen it. `name` is what the token is listed under — name it after the thing you are pasting it into, because that is what you will be reading when you decide whether to - revoke it. `expiresInDays` defaults to 365." + revoke it. `expiresInDays` defaults to 365. `mirrorToAppleHealth` (default false) lets the HealthLog app write + the token's readings into Apple Health; it can be changed later at `PATCH /api/tokens/{id}`." CreateWorkoutTokenRequest: type: object properties: @@ -28178,6 +28254,15 @@ components: - name description: "Mint a document-upload Bearer. No `scope` field: the endpoint mints one shape. `name` is what the token is listed under; name it after the system it is pasted into. `expiresInDays` defaults to 365." + UpdateMeasurementTokenRequest: + type: object + properties: + mirrorToAppleHealth: + type: boolean + required: + - mirrorToAppleHealth + additionalProperties: false + description: "The one setting a measurement token has. Strict: any other key is refused 422." RegisterRequest: type: object properties: @@ -38032,11 +38117,12 @@ components: source-priority.body.invalid_json, source-priority.body.invalid_shape; symptoms — symptoms.definition.limitReached, symptoms.definition.notFound, symptoms.episode.notOpen, symptoms.event.notFound, symptoms.invalid; timeline — timeline.invalid; tokens — - tokens.documents.ceiling_reached, tokens.measurements.ceiling_reached, tokens.workouts.ceiling_reached; - vaccination — vaccination.booster-invalid, vaccination.booster-no-antigen, - vaccination.booster-out-of-scope, vaccination.custom-vaccine-not-found, vaccination.custom.invalid, - vaccination.custom.name-taken, vaccination.custom.not-found, vaccination.encounter-not-found, - vaccination.identity-required, vaccination.invalid, vaccination.link.invalid, vaccination.not-found, + tokens.documents.ceiling_reached, tokens.measurements.ceiling_reached, + tokens.mirror.not_measurement_token, tokens.workouts.ceiling_reached; vaccination — + vaccination.booster-invalid, vaccination.booster-no-antigen, vaccination.booster-out-of-scope, + vaccination.custom-vaccine-not-found, vaccination.custom.invalid, vaccination.custom.name-taken, + vaccination.custom.not-found, vaccination.encounter-not-found, vaccination.identity-required, + vaccination.invalid, vaccination.link.invalid, vaccination.not-found, vaccination.practitioner-not-found; workout — workout.batch.invalid, workout.batch.payload_too_large, workout.batch.source_not_permitted, workout.batch.too_large, workout.delete.synced_source; (unprefixed) — about_me_conflict, ai_response_truncated, coach_prefs_conflict, dashboard_layout_conflict, empty_file, @@ -40800,6 +40886,11 @@ components: type: - string - "null" + ingestTokenId: + anyOf: + - type: string + maxLength: 64 + - type: "null" syncVersion: type: integer minimum: -9007199254740991 @@ -47391,6 +47482,10 @@ components: type: boolean description: Revoked tokens stay in the list rather than disappearing, so a client must filter on this rather than treat presence as validity. + mirrorToAppleHealth: + type: boolean + description: "A measurement token's Apple Health setting: whether the HealthLog app writes the readings this token sent + into Apple Health. Changed with `PATCH /api/tokens/{id}`; false on every other token." required: - id - name @@ -47399,6 +47494,36 @@ components: - expiresAt - createdAt - revoked + - mirrorToAppleHealth + additionalProperties: false + MeasurementTokenSummaryEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/MeasurementTokenSummaryResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + MeasurementTokenSummaryResponse: + type: object + properties: + unattributedReadings: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: "Live EXTERNAL readings that belong to no token: sent before readings were linked to their token, by an + account that held more than one. No token's Apple Health setting reaches them." + required: + - unattributedReadings additionalProperties: false CreateMeasurementTokenEnvelope: type: object @@ -47532,6 +47657,34 @@ components: - data - error additionalProperties: false + UpdateMeasurementTokenEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/UpdateMeasurementTokenResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + UpdateMeasurementTokenResponse: + type: object + properties: + id: + type: string + mirrorToAppleHealth: + type: boolean + required: + - id + - mirrorToAppleHealth + additionalProperties: false RegisterEnvelope: type: object properties: @@ -48111,12 +48264,37 @@ components: additionalProperties: false description: Menstrual cycles, observed and forward-predicted. Present on every page, empty arrays when the account has the cycle module off. + measurementMirror: + type: object + properties: + upserts: + type: array + items: + $ref: "#/components/schemas/SyncMeasurementMirrorUpsert" + tombstones: + maxItems: 0 + type: array + items: + type: object + properties: + id: + type: string + required: + - id + additionalProperties: false + description: "Always empty: a deleted reading is a measurement tombstone." + required: + - upserts + - tombstones + additionalProperties: false + description: Apple Health write-back changes for readings that are otherwise unchanged. Apply after `measurements`. required: - measurements - mood - intakes - cycleDays - cycles + - measurementMirror additionalProperties: false required: - serverNow @@ -48125,10 +48303,11 @@ components: - cursorExpired - changes additionalProperties: false - description: "Multi-domain delta page: measurements, mood, intakes, cycle day logs and cycles. One opaque multi-domain - keyset cursor; tombstones apply before upserts within each domain. Tombstone identity: measurements key on - externalId, every other domain on server id. All five keys are present on every response, including the - `cursorExpired` short-circuit — a domain with nothing to report sends empty arrays rather than being omitted." + description: "Multi-domain delta page: measurements, mood, intakes, cycle day logs, cycles, and the Apple Health + write-back changes of otherwise unchanged readings. One opaque multi-domain keyset cursor; tombstones apply + before upserts within each domain. Tombstone identity: measurements key on externalId, every other domain on + server id. All six keys are present on every response, including the `cursorExpired` short-circuit — a domain + with nothing to report sends empty arrays rather than being omitted." SyncMeasurementUpsert: type: object properties: @@ -48221,6 +48400,10 @@ components: type: string 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)))$ + appleHealthMirror: + type: boolean + description: Whether the HealthLog app writes this reading into Apple Health. Server-resolved; mirror exactly the rows + that say true. dayKey: type: string sampleCount: @@ -48280,6 +48463,7 @@ components: - externalId - syncVersion - updatedAt + - appleHealthMirror additionalProperties: false MeasurementSource: type: string @@ -48582,6 +48766,21 @@ components: - updatedAt additionalProperties: false description: A soft-deleted menstrual cycle, keyed on server `id`. Apply before upserts within the domain page. + SyncMeasurementMirrorUpsert: + type: object + properties: + id: + type: string + description: The measurement's server id. + appleHealthMirror: + type: boolean + description: The reading's write-back as it now stands. False means stop writing it; samples already in Apple Health stay. + required: + - id + - appleHealthMirror + additionalProperties: false + description: A reading whose `appleHealthMirror` changed because the account switched the Apple Health setting of the + token that sent it. The reading itself did not change and is not re-sent in `measurements`. GetMeasurementsSeriesBatchResponse: type: object properties: @@ -49017,6 +49216,11 @@ components: type: string 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)))$ + appleHealthMirror: + description: "Whether the HealthLog app writes this reading into Apple Health. Resolved by the server on every stored + row: true for WITHINGS, IMPORT and MANUAL, for an EXTERNAL row whatever its account chose for the token that + sent it, false otherwise. Absent on the synthesised list rows." + type: boolean dayKey: type: string sampleCount: @@ -49258,6 +49462,11 @@ components: type: string 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)))$ + appleHealthMirror: + description: "Whether the HealthLog app writes this reading into Apple Health. Resolved by the server on every stored + row: true for WITHINGS, IMPORT and MANUAL, for an EXTERNAL row whatever its account chose for the token that + sent it, false otherwise. Absent on the synthesised list rows." + type: boolean dayKey: type: string sampleCount: diff --git a/src/lib/openapi/routes/auth.ts b/src/lib/openapi/routes/auth.ts index 31795eff0..409078787 100644 --- a/src/lib/openapi/routes/auth.ts +++ b/src/lib/openapi/routes/auth.ts @@ -34,6 +34,7 @@ import { oidcNativeTokenSchema } from "@/lib/validations/oidc-native"; import { createDocumentTokenSchema, createMeasurementTokenSchema, + updateMeasurementTokenSchema, createWorkoutTokenSchema, } from "@/lib/validations/tokens"; import { nativeHandoffTokenSchema } from "@/lib/validations/native-handoff"; @@ -484,9 +485,36 @@ const apiTokenInfo = z .describe( "Revoked tokens stay in the list rather than disappearing, so a client must filter on this rather than treat presence as validity.", ), + mirrorToAppleHealth: z + .boolean() + .describe( + "A measurement token's Apple Health setting: whether the HealthLog app writes the readings this token sent into Apple Health. Changed with `PATCH /api/tokens/{id}`; false on every other token.", + ), }) .meta({ id: "ApiTokenInfo" }); +const updateMeasurementTokenRequest = updateMeasurementTokenSchema.meta({ + id: "UpdateMeasurementTokenRequest", + description: + "The one setting a measurement token has. Strict: any other key is refused 422.", +}); + +const updateMeasurementTokenResponse = z + .object({ id: z.string(), mirrorToAppleHealth: z.boolean() }) + .meta({ id: "UpdateMeasurementTokenResponse" }); + +const measurementTokenSummaryResponse = z + .object({ + unattributedReadings: z + .number() + .int() + .nonnegative() + .describe( + "Live EXTERNAL readings that belong to no token: sent before readings were linked to their token, by an account that held more than one. No token's Apple Health setting reaches them.", + ), + }) + .meta({ id: "MeasurementTokenSummaryResponse" }); + const apiTokenListResponse = z .array(apiTokenInfo) .meta({ id: "ApiTokenListResponse" }); @@ -494,7 +522,7 @@ const apiTokenListResponse = z const createMeasurementTokenRequest = createMeasurementTokenSchema.meta({ id: "CreateMeasurementTokenRequest", description: - "Mint a measurement-ingest Bearer. There is no `scope` field: this endpoint mints one shape and the permission array is a literal, so no request can widen it. `name` is what the token is listed under — name it after the thing you are pasting it into, because that is what you will be reading when you decide whether to revoke it. `expiresInDays` defaults to 365.", + "Mint a measurement-ingest Bearer. There is no `scope` field: this endpoint mints one shape and the permission array is a literal, so no request can widen it. `name` is what the token is listed under — name it after the thing you are pasting it into, because that is what you will be reading when you decide whether to revoke it. `expiresInDays` defaults to 365. `mirrorToAppleHealth` (default false) lets the HealthLog app write the token's readings into Apple Health; it can be changed later at `PATCH /api/tokens/{id}`.", }); const createMeasurementTokenResponse = z @@ -1944,6 +1972,26 @@ export const authPaths: NonNullable = { }, }, "/api/tokens/measurements": { + get: { + tags: ["Auth"], + summary: "Readings no measurement token reaches", + description: + "Counts the caller's live EXTERNAL readings that are linked to no token. Readings are linked to the token that sent them since v1.46; older ones were linked by a one-time pass only when the account held exactly one measurement token, so the rest belong to none and no token's Apple Health setting reaches them. Cookie session only.", + responses: { + "200": { + description: "The count.", + content: { + "application/json": { + schema: dataEnvelope( + measurementTokenSummaryResponse, + "MeasurementTokenSummaryEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, post: { tags: ["Auth"], summary: "Mint a measurement-ingest token", @@ -2170,6 +2218,42 @@ export const authPaths: NonNullable = { ...stdResponses, }, }, + patch: { + tags: ["Auth"], + summary: "Change a measurement token's Apple Health setting", + description: + "Sets whether the HealthLog app writes the readings this measurement token sent into Apple Health. The readings keep their `EXTERNAL` source and are not rewritten: every measurement row resolves `appleHealthMirror` from the token at read time, and a paired client learns of the change through the `measurementMirror` domain of `GET /api/sync/changes`. Turning it off stops further writes; samples already in Apple Health stay. A revoked token is accepted, since its readings stay in the record. Cookie session only, the caller's own tokens only (another account's answers 404). Audited as `tokens.measurements.mirror.update` when the value changes. 30 changes per account per minute. Not gated on the instance-wide API switch: no credential is created or extended.", + requestParams: { path: z.object({ id: z.string() }) }, + requestBody: { + required: true, + content: { + "application/json": { schema: updateMeasurementTokenRequest }, + }, + }, + responses: { + "200": { + description: "The setting as it now stands.", + content: { + "application/json": { + schema: dataEnvelope( + updateMeasurementTokenResponse, + "UpdateMeasurementTokenEnvelope", + ), + }, + }, + }, + "404": { + description: "Token not found or not owned by the caller.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + "422": { + description: + "Validation failed, or the token is not a measurement token (`meta.errorCode` = `tokens.mirror.not_measurement_token`): a wildcard, connector or other narrow token has no such setting.", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, }, // ── Appended: registration, credential rotation, the OIDC entry point and // the Codex device-auth flow. diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index 8415c13b8..c8a3dc8af 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -332,6 +332,12 @@ export const measurementResource = z .describe("Soft-delete tombstone; null on a live row."), createdAt: isoDateTime().optional(), updatedAt: isoDateTime().optional(), + appleHealthMirror: z + .boolean() + .optional() + .describe( + "Whether the HealthLog app writes this reading into Apple Health. Resolved by the server on every stored row: true for WITHINGS, IMPORT and MANUAL, for an EXTERNAL row whatever its account chose for the token that sent it, false otherwise. Absent on the synthesised list rows.", + ), // Synthesised-row extras — present only on the collapsed day-sum mode. dayKey: z.string().optional(), sampleCount: z.number().int().optional(), diff --git a/src/lib/openapi/routes/sync.ts b/src/lib/openapi/routes/sync.ts index 0a690d5f8..5444c6aac 100644 --- a/src/lib/openapi/routes/sync.ts +++ b/src/lib/openapi/routes/sync.ts @@ -75,9 +75,29 @@ const syncMeasurementUpsert = measurementResource "LWW reconciliation counter; echo to keep the mirror monotonic.", ), updatedAt: isoDateTime(), + appleHealthMirror: z + .boolean() + .describe( + "Whether the HealthLog app writes this reading into Apple Health. Server-resolved; mirror exactly the rows that say true.", + ), }) .meta({ id: "SyncMeasurementUpsert" }); +const syncMeasurementMirrorUpsert = z + .object({ + id: z.string().describe("The measurement's server id."), + appleHealthMirror: z + .boolean() + .describe( + "The reading's write-back as it now stands. False means stop writing it; samples already in Apple Health stay.", + ), + }) + .meta({ + id: "SyncMeasurementMirrorUpsert", + description: + "A reading whose `appleHealthMirror` changed because the account switched the Apple Health setting of the token that sent it. The reading itself did not change and is not re-sent in `measurements`.", + }); + const syncMeasurementTombstone = z .object({ id: z.string(), @@ -294,12 +314,25 @@ export const syncChangesResponse = z .describe( "Menstrual cycles, observed and forward-predicted. Present on every page, empty arrays when the account has the cycle module off.", ), + measurementMirror: z + .object({ + upserts: z.array(syncMeasurementMirrorUpsert), + tombstones: z + .array(z.object({ id: z.string() })) + .max(0) + .describe( + "Always empty: a deleted reading is a measurement tombstone.", + ), + }) + .describe( + "Apple Health write-back changes for readings that are otherwise unchanged. Apply after `measurements`.", + ), }), }) .meta({ id: "SyncChangesResponse", description: - "Multi-domain delta page: measurements, mood, intakes, cycle day logs and cycles. One opaque multi-domain keyset cursor; tombstones apply before upserts within each domain. Tombstone identity: measurements key on externalId, every other domain on server id. All five keys are present on every response, including the `cursorExpired` short-circuit — a domain with nothing to report sends empty arrays rather than being omitted.", + "Multi-domain delta page: measurements, mood, intakes, cycle day logs, cycles, and the Apple Health write-back changes of otherwise unchanged readings. One opaque multi-domain keyset cursor; tombstones apply before upserts within each domain. Tombstone identity: measurements key on externalId, every other domain on server id. All six keys are present on every response, including the `cursorExpired` short-circuit — a domain with nothing to report sends empty arrays rather than being omitted.", }); export const syncPaths: NonNullable = { @@ -327,7 +360,7 @@ export const syncPaths: NonNullable = { tags: ["Sync"], summary: "Multi-domain delta feed", description: - "Incremental catch-up after the first-pair backfill (never a replacement for it). Pages over one opaque keyset cursor spanning five domains — measurements, mood, medication intakes, cycle day logs and cycles. Each domain carries `tombstones` (soft-deleted rows) and `upserts` (live rows); apply tombstones before upserts within a domain. Tombstone identity is `externalId` for measurements and the server `id` everywhere else. `cursorExpired: true` forces a clean re-init and the page is empty in every domain.", + "Incremental catch-up after the first-pair backfill (never a replacement for it). Pages over one opaque keyset cursor spanning six domains — measurements, mood, medication intakes, cycle day logs, cycles, and `measurementMirror` (the Apple Health write-back of readings whose token setting changed). Each domain carries `tombstones` (soft-deleted rows) and `upserts` (live rows); apply tombstones before upserts within a domain. Tombstone identity is `externalId` for measurements and the server `id` everywhere else. `cursorExpired: true` forces a clean re-init and the page is empty in every domain.", requestParams: { query: syncChangesQuery, }, From d3385478a7796cd6afb6bcc06bc70dcf2a1841c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 19:28:05 +0200 Subject: [PATCH 10/10] feat(settings): switch Apple Health write-back per measurement token Each measurement token row carries a switch, the mint form an opt-in, and the card a notice when readings exist that no token reaches. Copy in all nine locales. The endpoint catalogue moves to its own file and the revoked list out of the token card. --- messages/de.json | 9 +- messages/en.json | 9 +- messages/es.json | 9 +- messages/fr.json | 9 +- messages/it.json | 9 +- messages/ko.json | 9 +- messages/nl.json | 9 +- messages/pl.json | 9 +- messages/pt-BR.json | 9 +- .../baselines/source-function-lines.json | 4 +- .../baselines/version-stamp-comments.json | 4 +- .../apple-health-mirror-token.test.tsx | 124 ++++++ .../settings/api-endpoints-card.tsx | 130 +++++++ src/components/settings/api-section.tsx | 367 +++++++----------- .../settings/apple-health-mirror-token.tsx | 138 +++++++ src/lib/query-keys/integrations.ts | 3 + 16 files changed, 613 insertions(+), 238 deletions(-) create mode 100644 src/components/settings/__tests__/apple-health-mirror-token.test.tsx create mode 100644 src/components/settings/api-endpoints-card.tsx create mode 100644 src/components/settings/apple-health-mirror-token.tsx diff --git a/messages/de.json b/messages/de.json index 52c9e2293..2a3d40ac2 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6855,7 +6855,14 @@ "detail": "Das Token wird nur einmal angezeigt.", "scopeNote": "Es kann ausschließlich neue Werte in deine eigene Akte eintragen. Es kann deine Daten nicht lesen, nichts ändern oder löschen und kein weiteres Token erstellen. Werte werden als von einem externen Gerät gekennzeichnet, damit du sie später erkennst, und lassen sich jederzeit korrigieren.", "createFailed": "Das Token konnte nicht erstellt werden. Bitte versuche es erneut.", - "namePlaceholder": "Name, z. B. Home Assistant" + "namePlaceholder": "Name, z. B. Home Assistant", + "appleHealth": { + "label": "Messwerte in Apple Health schreiben", + "createHint": "Die HealthLog-App auf deinem iPhone trägt die Werte dieses Tokens dann in Apple Health ein. Du kannst das später in der Token-Liste unten ändern.", + "toggleAria": "Werte von {name} in Apple Health schreiben", + "updateFailed": "Die Apple-Health-Einstellung konnte nicht geändert werden. Bitte versuche es erneut.", + "unattributed": "Werte, die vor dieser Einstellung gesendet wurden, lassen sich keinem Token zuordnen und bleiben deshalb außerhalb von Apple Health." + } }, "workoutsToken": { "title": "Training-Tokens", diff --git a/messages/en.json b/messages/en.json index 00826c3db..97234ceaf 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6855,7 +6855,14 @@ "detail": "The token is shown once.", "scopeNote": "It can only add new readings to your own record. It cannot read your data, change or delete anything, or create another token. Readings are marked as coming from an external device, so you can tell them apart later, and you can still correct them yourself.", "createFailed": "The token could not be created. Please try again.", - "namePlaceholder": "Name, e.g. Home Assistant" + "namePlaceholder": "Name, e.g. Home Assistant", + "appleHealth": { + "label": "Write readings to Apple Health", + "createHint": "The HealthLog app on your iPhone then adds this token's readings to Apple Health. You can change this later in the token list below.", + "toggleAria": "Write readings from {name} to Apple Health", + "updateFailed": "The Apple Health setting could not be changed. Please try again.", + "unattributed": "Readings sent before this setting existed can't be matched to a token, so they stay out of Apple Health." + } }, "workoutsToken": { "title": "Workout tokens", diff --git a/messages/es.json b/messages/es.json index ecc6d0e7c..3ca0cf9fa 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6855,7 +6855,14 @@ "detail": "El token se muestra una sola vez.", "scopeNote": "Solo puede añadir mediciones nuevas a tu propio historial: no puede leer tus datos, ni modificar o borrar nada, ni crear otro token. Las mediciones se marcan como procedentes de un dispositivo externo, así las distingues más tarde, y puedes corregirlas igualmente.", "createFailed": "No se pudo crear el token. Inténtalo de nuevo.", - "namePlaceholder": "Nombre, p. ej. Home Assistant" + "namePlaceholder": "Nombre, p. ej. Home Assistant", + "appleHealth": { + "label": "Escribir mediciones en Apple Health", + "createHint": "La app HealthLog de tu iPhone añadirá entonces las mediciones de este token a Apple Health. Puedes cambiarlo más tarde en la lista de tokens de abajo.", + "toggleAria": "Escribir las mediciones de {name} en Apple Health", + "updateFailed": "No se pudo cambiar el ajuste de Apple Health. Inténtalo de nuevo.", + "unattributed": "Las mediciones enviadas antes de que existiera este ajuste no se pueden asociar a ningún token, así que quedan fuera de Apple Health." + } }, "workoutsToken": { "title": "Tokens de entrenamientos", diff --git a/messages/fr.json b/messages/fr.json index 69fbcdfb8..106d1d321 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6855,7 +6855,14 @@ "detail": "Le jeton n'est affiché qu'une fois.", "scopeNote": "Il peut uniquement ajouter de nouvelles mesures à votre propre dossier : il ne peut pas lire vos données, ni modifier ou supprimer quoi que ce soit, ni créer un autre jeton. Les mesures sont signalées comme provenant d'un appareil externe, ce qui vous permet de les reconnaître plus tard, et vous pouvez toujours les corriger.", "createFailed": "Le jeton n'a pas pu être créé. Veuillez réessayer.", - "namePlaceholder": "Nom, p. ex. Home Assistant" + "namePlaceholder": "Nom, p. ex. Home Assistant", + "appleHealth": { + "label": "Écrire les mesures dans Apple Health", + "createHint": "L'app HealthLog de votre iPhone ajoutera alors les mesures de ce jeton à Apple Health. Vous pourrez modifier ce choix plus tard dans la liste des jetons ci-dessous.", + "toggleAria": "Écrire les mesures de {name} dans Apple Health", + "updateFailed": "Le réglage Apple Health n'a pas pu être modifié. Veuillez réessayer.", + "unattributed": "Les mesures envoyées avant l'existence de ce réglage ne peuvent être rattachées à aucun jeton : elles restent donc hors d'Apple Health." + } }, "workoutsToken": { "title": "Jetons d'entraînements", diff --git a/messages/it.json b/messages/it.json index 211211b5a..ea4d45364 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6855,7 +6855,14 @@ "detail": "Il token viene mostrato una sola volta.", "scopeNote": "Può soltanto aggiungere nuove misurazioni alla tua cartella: non può leggere i tuoi dati, modificare o eliminare nulla, né creare un altro token. Le misurazioni vengono contrassegnate come provenienti da un dispositivo esterno, così le riconosci in seguito, e puoi comunque correggerle.", "createFailed": "Non è stato possibile creare il token. Riprova.", - "namePlaceholder": "Nome, ad es. Home Assistant" + "namePlaceholder": "Nome, ad es. Home Assistant", + "appleHealth": { + "label": "Scrivi le misurazioni in Apple Health", + "createHint": "L'app HealthLog sul tuo iPhone aggiungerà quindi le misurazioni di questo token ad Apple Health. Puoi cambiarlo più tardi nell'elenco dei token qui sotto.", + "toggleAria": "Scrivi le misurazioni di {name} in Apple Health", + "updateFailed": "Non è stato possibile modificare l'impostazione di Apple Health. Riprova.", + "unattributed": "Le misurazioni inviate prima che esistesse questa impostazione non si possono associare a un token, quindi restano fuori da Apple Health." + } }, "workoutsToken": { "title": "Token per allenamenti", diff --git a/messages/ko.json b/messages/ko.json index 5069c1d45..6ec545b8e 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -6855,7 +6855,14 @@ "detail": "토큰은 한 번만 표시돼요.", "scopeNote": "본인 기록에 새 측정값을 추가하는 것만 할 수 있어요. 데이터를 읽거나, 무언가를 고치거나 지우거나, 다른 토큰을 만들 수는 없어요. 측정값은 외부 기기에서 온 것으로 표시되니 나중에 구분할 수 있고, 언제든 고칠 수 있어요.", "createFailed": "토큰을 만들지 못했어요. 다시 시도해 주세요.", - "namePlaceholder": "이름 (예: Home Assistant)" + "namePlaceholder": "이름 (예: Home Assistant)", + "appleHealth": { + "label": "측정값을 Apple Health에 기록", + "createHint": "그러면 iPhone의 HealthLog 앱이 이 토큰의 측정값을 Apple Health에 추가해요. 나중에 아래 토큰 목록에서 바꿀 수 있어요.", + "toggleAria": "{name}의 측정값을 Apple Health에 기록", + "updateFailed": "Apple Health 설정을 바꾸지 못했어요. 다시 시도해 주세요.", + "unattributed": "이 설정이 생기기 전에 보낸 측정값은 토큰과 연결할 수 없어서 Apple Health에 기록되지 않아요." + } }, "workoutsToken": { "title": "운동 토큰", diff --git a/messages/nl.json b/messages/nl.json index f091c3c1d..6415f4489 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -6855,7 +6855,14 @@ "detail": "Het token wordt één keer getoond.", "scopeNote": "Het kan alleen nieuwe metingen aan je eigen dossier toevoegen. Het kan je gegevens niet lezen, niets wijzigen of verwijderen en geen ander token aanmaken. Metingen worden gemarkeerd als afkomstig van een extern apparaat, zodat je ze later kunt onderscheiden, en je kunt ze nog steeds zelf corrigeren.", "createFailed": "Het token kon niet worden aangemaakt. Probeer het opnieuw.", - "namePlaceholder": "Naam, bijv. Home Assistant" + "namePlaceholder": "Naam, bijv. Home Assistant", + "appleHealth": { + "label": "Metingen naar Apple Health schrijven", + "createHint": "De HealthLog-app op je iPhone zet de metingen van dit token dan in Apple Health. Je kunt dit later wijzigen in de tokenlijst hieronder.", + "toggleAria": "Metingen van {name} naar Apple Health schrijven", + "updateFailed": "De Apple Health-instelling kon niet worden gewijzigd. Probeer het opnieuw.", + "unattributed": "Metingen die zijn verstuurd voordat deze instelling bestond, kunnen niet aan een token worden gekoppeld en blijven daarom buiten Apple Health." + } }, "workoutsToken": { "title": "Trainingstokens", diff --git a/messages/pl.json b/messages/pl.json index 947424424..cb5dc32b7 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6855,7 +6855,14 @@ "detail": "Token pokazujemy tylko raz.", "scopeNote": "Może wyłącznie dodawać nowe pomiary do Twojej dokumentacji: nie odczyta danych, niczego nie zmieni ani nie usunie i nie utworzy kolejnego tokenu. Pomiary są oznaczane jako pochodzące z urządzenia zewnętrznego, dzięki czemu później je rozpoznasz, i nadal możesz je poprawić.", "createFailed": "Nie udało się utworzyć tokenu. Spróbuj ponownie.", - "namePlaceholder": "Nazwa, np. Home Assistant" + "namePlaceholder": "Nazwa, np. Home Assistant", + "appleHealth": { + "label": "Zapisuj pomiary w Apple Health", + "createHint": "Aplikacja HealthLog na Twoim iPhonie doda wtedy pomiary z tego tokenu do Apple Health. Możesz to później zmienić na liście tokenów poniżej.", + "toggleAria": "Zapisuj pomiary z {name} w Apple Health", + "updateFailed": "Nie udało się zmienić ustawienia Apple Health. Spróbuj ponownie.", + "unattributed": "Pomiarów wysłanych, zanim pojawiło się to ustawienie, nie da się przypisać do żadnego tokenu, więc nie trafiają do Apple Health." + } }, "workoutsToken": { "title": "Tokeny treningów", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 277297c97..aa102f900 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -6855,7 +6855,14 @@ "detail": "O token é exibido uma vez.", "scopeNote": "Ele só pode adicionar novas leituras ao seu próprio registro. Não pode ler seus dados, alterar ou excluir nada, nem criar outro token. As leituras são marcadas como vindas de um dispositivo externo, para você distingui-las depois, e você ainda pode corrigi-las você mesmo.", "createFailed": "O token não pôde ser criado. Tente novamente.", - "namePlaceholder": "Nome, ex.: Home Assistant" + "namePlaceholder": "Nome, ex.: Home Assistant", + "appleHealth": { + "label": "Gravar medições no Apple Health", + "createHint": "O app HealthLog no seu iPhone passa a adicionar as medições deste token ao Apple Health. Você pode mudar isso depois na lista de tokens abaixo.", + "toggleAria": "Gravar as medições de {name} no Apple Health", + "updateFailed": "Não foi possível alterar a configuração do Apple Health. Tente novamente.", + "unattributed": "As medições enviadas antes de esta configuração existir não podem ser associadas a um token, por isso ficam fora do Apple Health." + } }, "workoutsToken": { "title": "Tokens de treino", diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index 4dcbcf91c..8c6ac48ff 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 355, - "total": 114773, + "total": 114676, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314, @@ -200,7 +200,7 @@ "components/settings/ai/codex-provider-form.tsx :: CodexProviderForm": 280, "components/settings/ai/fallback-chain-card.tsx :: FallbackChainCard": 228, "components/settings/ai/runtime-actions-row.tsx :: RuntimeActionsRow": 282, - "components/settings/api-section.tsx :: ApiTokensCard": 366, + "components/settings/api-section.tsx :: ApiTokensCard": 269, "components/settings/coach-nudge-card.tsx :: CoachNudgeCard": 251, "components/settings/coach-prefs-section.tsx :: CoachPrefsSection": 350, "components/settings/environment-section.tsx :: EnvironmentSection": 666, diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json index 32b18fa49..0e84d0c5f 100644 --- a/src/__tests__/baselines/version-stamp-comments.json +++ b/src/__tests__/baselines/version-stamp-comments.json @@ -1,7 +1,7 @@ { "$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.", "count": 2898, - "total": 9297, + "total": 9296, "entries": { "__tests__/account-payload-consumer-guard.test.ts": 2, "__tests__/acting-account-boundary-guard.test.ts": 7, @@ -1359,7 +1359,7 @@ "components/settings/ai/response-timeout-card.tsx": 1, "components/settings/ai/shared.ts": 4, "components/settings/anamnesis-section.tsx": 2, - "components/settings/api-section.tsx": 2, + "components/settings/api-section.tsx": 1, "components/settings/coach-memory-section.tsx": 3, "components/settings/coach-nudge-card.tsx": 4, "components/settings/coach-prefs-section.tsx": 2, diff --git a/src/components/settings/__tests__/apple-health-mirror-token.test.tsx b/src/components/settings/__tests__/apple-health-mirror-token.test.tsx new file mode 100644 index 000000000..c06a93b50 --- /dev/null +++ b/src/components/settings/__tests__/apple-health-mirror-token.test.tsx @@ -0,0 +1,124 @@ +/** + * The Apple Health write-back controls on the API settings (#1272). + * + * SSR-only, as the rest of this directory: the render pins which token rows + * carry the switch and in which state, when the notice about readings that + * belong to no token shows, and the write the switch sends is pinned against + * a stubbed `fetch`. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; + +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ isAuthenticated: true }), +})); + +import { + AppleHealthMirrorOptIn, + AppleHealthMirrorSwitch, + UnattributedReadingsNotice, + patchTokenMirror, +} from "../apple-health-mirror-token"; + +function render(node: React.ReactNode, client = new QueryClient()) { + return renderToStaticMarkup( + + {node} + , + ); +} + +const token = (permissions: string[], mirrorToAppleHealth = false) => ({ + id: "tok-1", + name: "Scale", + permissions, + mirrorToAppleHealth, +}); + +describe("the switch on a token row", () => { + it("appears on a measurement token only", () => { + expect( + render(), + ).toContain('data-slot="apple-health-mirror-switch"'); + for (const permissions of [["*"], ["health:read"], ["workouts:write"]]) { + expect( + render(), + ).toBe(""); + } + }); + + it("shows the stored setting and names the token", () => { + const on = render( + , + ); + expect(on).toContain('aria-checked="true"'); + expect(on).toContain("Write readings from Scale to Apple Health"); + const off = render( + , + ); + expect(off).toContain('aria-checked="false"'); + }); +}); + +describe("the opt-in on the mint form", () => { + it("reads as the setting with its explanation", () => { + const html = render( + {}} />, + ); + expect(html).toContain("Write readings to Apple Health"); + expect(html).toContain("adds this token's readings to Apple Health"); + }); +}); + +describe("the notice about readings no token reaches", () => { + function withSummary(unattributedReadings: number) { + const client = new QueryClient(); + client.setQueryData(queryKeys.measurementTokenSummary(), { + unattributedReadings, + }); + return render(, client); + } + + it("shows only when there are such readings", () => { + expect(withSummary(3)).toContain( + "Readings sent before this setting existed can't be matched to a token", + ); + expect(withSummary(0)).toBe(""); + expect(render()).toBe(""); + }); +}); + +describe("the write", () => { + let fetchMock: ReturnType; + beforeEach(() => { + fetchMock = vi.fn( + async () => + new Response( + JSON.stringify({ + data: { id: "tok 1", mirrorToAppleHealth: true }, + error: null, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + vi.stubGlobal("fetch", fetchMock); + }); + afterEach(() => vi.unstubAllGlobals()); + + it("patches the token with the one field", async () => { + await expect(patchTokenMirror("tok 1", true)).resolves.toEqual({ + id: "tok 1", + mirrorToAppleHealth: true, + }); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("/api/tokens/tok%201"); + expect(init.method).toBe("PATCH"); + expect(JSON.parse(init.body as string)).toEqual({ + mirrorToAppleHealth: true, + }); + }); +}); diff --git a/src/components/settings/api-endpoints-card.tsx b/src/components/settings/api-endpoints-card.tsx new file mode 100644 index 000000000..4f7889947 --- /dev/null +++ b/src/components/settings/api-endpoints-card.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { Key } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { SettingsCard } from "@/components/settings/settings-card"; +import { SettingsCardHeader } from "@/components/settings/_card-header"; +import { useTranslations } from "@/lib/i18n/context"; + +/** The ingest endpoints a token reaches, with the header and a body sketch. */ +export function ApiEndpointsCard() { + const { t } = useTranslations(); + + // Dual-render: `. The endpoint catalogue is array-driven + // so adding a row remains a one-line edit on both surfaces — which is + // what the two measurement ingest rows below cost. + const endpoints = [ + { + method: "POST", + path: "/api/ingest/medication", + auth: "Authorization: Bearer hlk_...", + example: `{ "medicationName": "...", "scheduledFor": "..." }`, + }, + { + method: "POST", + path: "/api/measurements", + auth: "Authorization: Bearer hlk_...", + example: `{ "type": "WEIGHT", "value": 82.4, "measuredAt": "..." }`, + }, + { + method: "POST", + path: "/api/measurements/batch", + auth: "Authorization: Bearer hlk_...", + example: `{ "entries": [ { "hkIdentifier": "...", "value": 1, ... } ] }`, + }, + { + method: "POST", + path: "/api/workouts/batch", + auth: "Authorization: Bearer hlk_...", + example: `{ "workouts": [ { "sportType": "running", "startedAt": "...", "endedAt": "...", "externalId": "..." } ] }`, + }, + { + method: "POST", + path: "/api/documents/inbound", + auth: "Authorization: Bearer hlk_...", + example: `multipart: file, title, documentDate, sourceSystem, sourceId`, + }, + ]; + + return ( + + + + {/* Desktop table — verbatim layout for md+. */} +
+ + + + + + + + + + + {endpoints.map((endpoint) => ( + + + + + + + ))} + +
+ {t("settings.apiEndpointMethod")} + + {t("settings.apiEndpointPath")} + + {t("settings.apiEndpointAuth")} + + {t("settings.apiEndpointExample")} +
{endpoint.method}{endpoint.path}{endpoint.auth}{endpoint.example}
+
+ + {/* Mobile card list — each endpoint stacks its columns vertically + with a labelled meta row. No horizontal scroll. */} +
    + {endpoints.map((endpoint) => ( +
  • +
    + + {endpoint.method} + + {endpoint.path} +
    +
    +

    + {t("settings.apiEndpointAuth")} +

    + {endpoint.auth} +
    +
    +

    + {t("settings.apiEndpointExample")} +

    + + {endpoint.example} + +
    +
  • + ))} +
+
+ ); +} diff --git a/src/components/settings/api-section.tsx b/src/components/settings/api-section.tsx index 4279624c7..241d1f45f 100644 --- a/src/components/settings/api-section.tsx +++ b/src/components/settings/api-section.tsx @@ -31,6 +31,12 @@ import { formatDate, formatDateTime } from "@/lib/format"; import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; +import { ApiEndpointsCard } from "@/components/settings/api-endpoints-card"; +import { + AppleHealthMirrorOptIn, + AppleHealthMirrorSwitch, + UnattributedReadingsNotice, +} from "@/components/settings/apple-health-mirror-token"; import { recentProofErrorMessage, throwIfReproofRequired, @@ -45,6 +51,7 @@ interface ApiTokenInfo { expiresAt: string | null; createdAt: string; revoked: boolean; + mirrorToAppleHealth: boolean; } export function ApiSection() { @@ -78,6 +85,8 @@ function MeasurementTokensCard() { icon={Upload} slot="settings-measurements-token-created" anchor="measurements-token" + mirrorOptIn + footer={} copy={{ title: t("settings.measurementsToken.title"), description: t("settings.measurementsToken.description"), @@ -147,7 +156,12 @@ function IngestTokenCard({ slot, anchor, copy, + mirrorOptIn = false, + footer, }: { + /** Offer the Apple Health write-back on the mint (measurement tokens). */ + mirrorOptIn?: boolean; + footer?: React.ReactNode; /** Deep-link anchor for the card (`/settings/api#`). */ anchor: string; endpoint: @@ -172,6 +186,7 @@ function IngestTokenCard({ const [creating, setCreating] = useState(false); const [tokenMsg, setTokenMsg] = useState(null); const [tokenCopied, setTokenCopied] = useState(false); + const [mirror, setMirror] = useState(false); // The token outlives the session, so minting one asks for a fresh proof // unless the session signed in or re-proved within five minutes. const recentProof = useRecentProof(); @@ -184,7 +199,10 @@ function IngestTokenCard({ try { // No scope field: the endpoint mints one shape, so there is nothing // here for a caller to choose or for this form to get wrong. - const body = JSON.stringify({ name: newName.trim() }); + const body = JSON.stringify({ + name: newName.trim(), + ...(mirrorOptIn && { mirrorToAppleHealth: mirror }), + }); const res = await recentProof.run(async () => throwIfReproofRequired( await apiFetchRaw(endpoint, { @@ -198,6 +216,7 @@ function IngestTokenCard({ if (res.ok) { setNewToken(json.data.token); setNewName(""); + setMirror(false); // The sibling card lists this token, so it has to hear about it. queryClient.invalidateQueries({ queryKey: queryKeys.tokens() }); } else { @@ -263,6 +282,12 @@ function IngestTokenCard({ {t("common.create")} + {mirrorOptIn && ( + + )} {newToken && (
{tokenMsg}

} + {footer}
); } -function ApiEndpointsCard() { - const { t } = useTranslations(); - - // v1.4.27 — dual-render: `. The endpoint catalogue is array-driven - // so adding a row remains a one-line edit on both surfaces — which is - // what the two measurement ingest rows below cost. - const endpoints = [ - { - method: "POST", - path: "/api/ingest/medication", - auth: "Authorization: Bearer hlk_...", - example: `{ "medicationName": "...", "scheduledFor": "..." }`, - }, - { - method: "POST", - path: "/api/measurements", - auth: "Authorization: Bearer hlk_...", - example: `{ "type": "WEIGHT", "value": 82.4, "measuredAt": "..." }`, - }, - { - method: "POST", - path: "/api/measurements/batch", - auth: "Authorization: Bearer hlk_...", - example: `{ "entries": [ { "hkIdentifier": "...", "value": 1, ... } ] }`, - }, - { - method: "POST", - path: "/api/workouts/batch", - auth: "Authorization: Bearer hlk_...", - example: `{ "workouts": [ { "sportType": "running", "startedAt": "...", "endedAt": "...", "externalId": "..." } ] }`, - }, - { - method: "POST", - path: "/api/documents/inbound", - auth: "Authorization: Bearer hlk_...", - example: `multipart: file, title, documentDate, sourceSystem, sourceId`, - }, - ]; - - return ( - - - - {/* Desktop table — verbatim layout for md+. */} -
- - - - - - - - - - - {endpoints.map((endpoint) => ( - - - - - - - ))} - -
- {t("settings.apiEndpointMethod")} - - {t("settings.apiEndpointPath")} - - {t("settings.apiEndpointAuth")} - - {t("settings.apiEndpointExample")} -
{endpoint.method}{endpoint.path}{endpoint.auth}{endpoint.example}
-
- - {/* Mobile card list — each endpoint stacks its columns vertically - with a labelled meta row. No horizontal scroll. */} -
    - {endpoints.map((endpoint) => ( -
  • -
    - - {endpoint.method} - - {endpoint.path} -
    -
    -

    - {t("settings.apiEndpointAuth")} -

    - {endpoint.auth} -
    -
    -

    - {t("settings.apiEndpointExample")} -

    - - {endpoint.example} - -
    -
  • - ))} -
-
- ); -} - function ApiTokensCard() { const { t } = useTranslations(); const { isAuthenticated } = useAuth(); const queryClient = useQueryClient(); - const [showRevokedTokens, setShowRevokedTokens] = useState(false); const { data: tokens, @@ -557,7 +461,10 @@ function ApiTokensCard() { key={tok.id} className={index % 2 === 0 ? "bg-muted/20" : ""} > - {tok.name} + + {tok.name} + + {tok.permissions.join(", ")} @@ -660,6 +567,7 @@ function ApiTokensCard() { ? formatDateTime(tok.lastUsedAt) : t("settings.tokenNeverUsed")}

+ {revokedTokens.length > 0 && ( -
- - {showRevokedTokens && ( - <> - {/* Desktop table — verbatim layout for md+. */} -
- - - - - - - - - - - {revokedTokens.map((tok, index) => ( - - - - - - - ))} - -
- {t("settings.tokenTableName")} - - {t("settings.tokenTablePermissions")} - - {t("settings.tokenTableCreated")} - - {t("settings.tokenTableLastUsed")} -
- {tok.name} - - {tok.permissions.join(", ")} - - {formatDate(tok.createdAt)} - - {tok.lastUsedAt - ? formatDateTime(tok.lastUsedAt) - : t("settings.tokenNeverUsed")} -
-
- - {/* Mobile card list — stacked meta layout, no - horizontal scroll. Revoked tokens are read-only so - no action footer is needed. */} -
    - {revokedTokens.map((tok) => ( -
  • -

    - {tok.name} -

    -

    - - {t("settings.tokenTablePermissions")}: - {" "} - {tok.permissions.join(", ")} -

    -

    - - {t("settings.tokenTableCreated")}: - {" "} - {formatDate(tok.createdAt)} -

    -

    - - {t("settings.tokenTableLastUsed")}: - {" "} - {tok.lastUsedAt - ? formatDateTime(tok.lastUsedAt) - : t("settings.tokenNeverUsed")} -

    -
  • - ))} -
- - )} -
+ )} )} @@ -787,3 +595,112 @@ function ApiTokensCard() { ); } + +/** The revoked tokens, folded away until asked for. Read-only but for the switch. */ +function RevokedTokens({ tokens: revokedTokens }: { tokens: ApiTokenInfo[] }) { + const { t } = useTranslations(); + const [showRevokedTokens, setShowRevokedTokens] = useState(false); + return ( +
+ + {showRevokedTokens && ( + <> + {/* Desktop table — verbatim layout for md+. */} +
+ + + + + + + + + + + {revokedTokens.map((tok, index) => ( + + + + + + + ))} + +
+ {t("settings.tokenTableName")} + + {t("settings.tokenTablePermissions")} + + {t("settings.tokenTableCreated")} + + {t("settings.tokenTableLastUsed")} +
+ {tok.name} + + + {tok.permissions.join(", ")} + + {formatDate(tok.createdAt)} + + {tok.lastUsedAt + ? formatDateTime(tok.lastUsedAt) + : t("settings.tokenNeverUsed")} +
+
+ + {/* Mobile card list — stacked meta layout, no + horizontal scroll. Revoked tokens are read-only so + no action footer is needed. */} +
    + {revokedTokens.map((tok) => ( +
  • +

    {tok.name}

    +

    + + {t("settings.tokenTablePermissions")}: + {" "} + {tok.permissions.join(", ")} +

    +

    + + {t("settings.tokenTableCreated")}: + {" "} + {formatDate(tok.createdAt)} +

    +

    + + {t("settings.tokenTableLastUsed")}: + {" "} + {tok.lastUsedAt + ? formatDateTime(tok.lastUsedAt) + : t("settings.tokenNeverUsed")} +

    + +
  • + ))} +
+ + )} +
+ ); +} diff --git a/src/components/settings/apple-health-mirror-token.tsx b/src/components/settings/apple-health-mirror-token.tsx new file mode 100644 index 000000000..843d352bf --- /dev/null +++ b/src/components/settings/apple-health-mirror-token.tsx @@ -0,0 +1,138 @@ +"use client"; + +/** + * A measurement token's one setting (#1272): whether the HealthLog app on the + * iPhone writes the readings that token sent into Apple Health. + * + * The readings keep their EXTERNAL source either way; the server resolves + * the decision per reading and the app follows it. Three pieces, used by the + * API settings: the switch on a measurement token's row, the opt-in on the + * mint form, and the notice about readings that belong to no token. + */ +import { useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; + +import { Checkbox } from "@/components/ui/checkbox"; +import { Switch } from "@/components/ui/switch"; +import { useAuth } from "@/hooks/use-auth"; +import { apiGet, apiPatch } from "@/lib/api/api-fetch"; +import { showErrorToast } from "@/lib/errors/error-toast"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; + +/** The scope a measurement token carries, and nothing else. */ +const MEASUREMENTS_WRITE = "measurements:write"; + +export function isMeasurementTokenInfo(permissions: readonly string[]) { + return permissions.length === 1 && permissions[0] === MEASUREMENTS_WRITE; +} + +/** `PATCH /api/tokens/{id}`: the one write behind the switch. */ +export function patchTokenMirror( + tokenId: string, + mirrorToAppleHealth: boolean, +) { + return apiPatch<{ id: string; mirrorToAppleHealth: boolean }>( + `/api/tokens/${encodeURIComponent(tokenId)}`, + { mirrorToAppleHealth }, + ); +} + +/** The switch on a measurement token's row; nothing on any other token. */ +export function AppleHealthMirrorSwitch({ + token, +}: { + token: { + id: string; + name: string; + permissions: string[]; + mirrorToAppleHealth: boolean; + }; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [saving, setSaving] = useState(false); + if (!isMeasurementTokenInfo(token.permissions)) return null; + + async function handleChange(next: boolean) { + setSaving(true); + try { + await patchTokenMirror(token.id, next); + await queryClient.invalidateQueries({ queryKey: queryKeys.tokens() }); + } catch (failure) { + showErrorToast(t("settings.measurementsToken.appleHealth.updateFailed"), { + cause: failure, + }); + } finally { + setSaving(false); + } + } + + return ( + + ); +} + +/** The opt-in on the measurement mint form. Off unless the person ticks it. */ +export function AppleHealthMirrorOptIn({ + checked, + onCheckedChange, +}: { + checked: boolean; + onCheckedChange: (next: boolean) => void; +}) { + const { t } = useTranslations(); + return ( + + ); +} + +/** + * Shown only when the account holds EXTERNAL readings no token reaches: sent + * before readings were linked to their token, by an account that held more + * than one, so no setting here can put them into Apple Health. + */ +export function UnattributedReadingsNotice() { + const { t } = useTranslations(); + const { isAuthenticated } = useAuth(); + const { data } = useQuery({ + queryKey: queryKeys.measurementTokenSummary(), + queryFn: () => + apiGet<{ unattributedReadings: number }>("/api/tokens/measurements"), + enabled: isAuthenticated, + }); + if (!data || !(data.unattributedReadings > 0)) return null; + return ( +

+ {t("settings.measurementsToken.appleHealth.unattributed")} +

+ ); +} diff --git a/src/lib/query-keys/integrations.ts b/src/lib/query-keys/integrations.ts index 11c0e3b90..732617e1f 100644 --- a/src/lib/query-keys/integrations.ts +++ b/src/lib/query-keys/integrations.ts @@ -5,6 +5,9 @@ */ export const integrationKeys = { tokens: () => ["tokens"] as const, + // How many EXTERNAL readings belong to no measurement token (#1272). Under + // the `["tokens"]` prefix, so a mint or a revoke refreshes it too. + measurementTokenSummary: () => ["tokens", "measurements", "summary"] as const, // v1.22.0 — MCP connector tokens (the dedicated `health:read` scope minted // for the remote MCP endpoint). Distinct key from `tokens()` so the MCP // settings card's mint / revoke mutations invalidate only this list.