Commit Graph
6426 Commits
Author SHA1 Message Date
Marc-André Bombeck 34caeeea3c test(onboarding): the AI panel names the variant it settled on
The panel paints before its status query answers, and every variant inside it
is conditional, so an assertion that a variant is absent could pass on a
question that had simply not been answered yet — which is how the journey for
the unmeasured-provider case passed on a retry against the very defect it was
written to catch.

The panel now carries the variant it settled on, absent until it has settled,
and the three journeys wait on that instead of racing it. The third journey
covers the state a freshly deployed instance is in before its first AI call:
no offer, and no claim of an outage either.
2026-09-11 11:15:39 +02:00
Marc-André Bombeck 41596d1cb8 docs: comments name what the code does, not how it was built
Thirty-one files carried internal build vocabulary and anchors pointing at
notes that are not in the repository: a reader of `checklist.ts` in a year
would find its rationale keyed to a research id they cannot open, inside a
comment that also names an internal build stage. Two test titles carried the
same anchors.

The version tags and the whole of the reasoning stay; only the vocabulary and
the dangling references go, with the few sentences that were built around an
anchor rewritten to stand on their own.
2026-09-11 11:03:04 +02:00
Marc-André Bombeck bdc23f34ff test(e2e): the instance-wide journeys run in one project only
`onboarding-ai-offer.spec.ts` seeds the operator's AI key into `app_settings`
and replaces every `provider_health` row for both shared provider tags, then
restores both. Its own header says the inputs are instance-wide and it
configures serial mode, but serial mode only orders tests within a file: the
spec was absent from the project filter, so the mobile project ran it in a
second worker concurrently with the desktop one, each holding and clearing
state the other was asserting on — and any other spec reading AI availability
alongside them.

`invite-registration.spec.ts` joins the list for the smaller reason its
neighbours carry: two projects registering from one address share the same
five-per-fifteen-minutes budget.
2026-09-11 10:58:50 +02:00
Marc-André Bombeck ba29a5e471 fix(onboarding): no shared-provider offer where the grant cannot be made
The done screen's one-tap button posts `POST /api/consent/ai/web`, a path the
demo instance's edge allowlist refuses — correctly, since the demo is a single
shared account and a receipt one visitor minted would turn the operator's
provider on for everyone after them. The button was offered anyway, so a
visitor who took it got the generic "give it another try in a moment" toast on
the one screen whose whole purpose is not to say things it cannot back.

The route withholds the offer under `DEMO_MODE`, which leaves the panel in its
neutral form, claiming nothing either way.
2026-09-11 10:58:25 +02:00
Marc-André Bombeck 97174f470d fix(demo): the shared demo account takes no free text and no new record
The demo allowlist had grown two entries it should not carry. The demo is one
published account every visitor signs into, so `PUT /api/coach/about-me` was
an anonymous write of arbitrary text into the Coach prompt, persisted for
every later visitor until the next reseed; it is gone, and the baseline step's
anamnesis card renders read-only under `DEMO_MODE` so no visitor meets the
refusal as a failed confirm. `POST /api/managed-profiles` bought nothing: the
route opens with a fresh-MFA requirement the demo account cannot satisfy, so
the guardian arm was limited to finishing without the profile either way.

The baseline profile write stays, and the block's preamble now describes what
is actually admitted and why the two refusals are refusals.
2026-09-11 10:57:42 +02:00
Marc-André Bombeck 4c2175a584 fix(auth): the register page reads a session off the account payload
`isAuthenticated` holds the last-known state off a localStorage marker when
`/api/auth/me` fails at the transport level or with a 5xx, so the shell does
not sign people out on a blip. The register page gated its "already signed in"
panel on that flag, and on that path there is no account payload: an invited
household member opening the link during a 502 window was shown "You are
signed in as ." and a sign-out posting to the failing endpoint, with no way to
register at all.

The gate is now the payload itself, which is the only positive evidence of a
session and is equally absent while the probe is in flight.
2026-09-11 10:55:33 +02:00
Marc-André Bombeck 5c194cc9b9 fix(settings): a fresh managed connection reads as connected again
The managed-record integration projection mapped the ledger's new `unknown`
state to `disconnected`, but that mapping runs after the connection map has
already established a real credential. A guardian who connected Withings on a
child's record saw "Not connected" until the first hourly sync landed, beside
a row whose own `connected` field said true — an invitation to revoke and redo
the grant they had just made.

`state` on this projection reports that a pipe exists, not that it is live;
liveness is the sync verdict the card beside the pill already reads. With no
ledger row and a credential present, the pipe exists.
2026-09-11 10:54:56 +02:00
Marc-André Bombeck e991fdf65d fix(onboarding): a guardian is not told their own modules are leaving
The confirm screen derived "Out of the navigation: …" from the acting
account's module map and rendered it in every arm. In the "someone I look
after" arm the completion never touches that account: it applies the
derivation to the managed record, or, when none was created, to nothing at
all. So a parent answering for a child was told Workouts, Cycle and Documents
were leaving their own navigation when nothing was going to move.

The arm now derives the list from an empty map, which makes it empty, and the
sentence disappears with it.
2026-09-11 10:54:14 +02:00
Marc-André Bombeck 65a62350ea fix(onboarding): the AI panel claims an outage only when one was seen
The done screen folded the `unknown` provider verdict into `unhealthy` and
painted "This instance's provider is not answering right now." `unknown` is
an empty `provider_health` table, which is the state of every instance until
its first AI call — so the most common path through the screen made a claim
about something nobody had measured. It now falls to the neutral panel, which
says nothing either way; the offer is withheld in both cases as before.

The projection itself drew the same unearned conclusion from staleness alone:
a quiet instance whose last success was two days old read `unhealthy` without
any failure behind it. A verdict now needs an observed one — a row whose last
outcome was a failure — and a merely old success reads `unknown`.
2026-09-11 10:53:41 +02:00
Marc-André Bombeck 1885b7867d Merge wave D — the shared provider is offered only when it is known to work
The done screen's assistant panel stops claiming that the instance's
provider already serves the person. A tri-state read over the operator's
own provider ledger decides between a one-tap offer that mints the
consent receipt, a plain "not answering right now" note, and today's
setup link; anything unknown, any read error, a managed profile and an
operator who has switched the assistant off all fall to no offer.

# Conflicts:
#	e2e/setup/global-setup.ts
2026-09-11 10:25:19 +02:00
Marc-André Bombeck 4d7b326fda Merge wave B — the connect step reads liveness, not a latched field
A provider with no sync history reports "unknown" instead of "connected",
so a fresh account is no longer told a device it never linked is already
delivering; the connect step resolves the sync verdict into four honest
tiles, records the flow's result only for a connection that is actually
serving, points an unconfigured provider at its credentials, and steps
over a source the account already has.
2026-09-11 10:24:54 +02:00
Marc-André Bombeck 3642bbe4de Merge wave C — the flow cannot strand, mislead or silently switch off
The getting-started checklist's data queries follow the same setup rule as
its visibility, so a settled account stops being told to do what it has
done; a failed completion leaves the confirm screen usable; an empty
multi-select is no longer recorded as an answer; the confirm screen names
the modules a run would take out of the navigation; the settings links it
offers work before the first completion; and demo mode can fill the
baseline it is shown.
2026-09-11 10:24:48 +02:00
Marc-André Bombeck 30339d8f7d Merge wave A — the invitation link lands on registration, never on login
The invite landing is public in the client shell as well as the proxy, the
edge answers the redirect itself so no shell mounts, and registration
refuses to run over a live session instead of silently swapping the
caller's cookie for the new account's.
2026-09-11 10:24:43 +02:00
Marc-André Bombeck d2f47e3bb5 fix(auth): the refusal code joins the published catalogue
Two structural guards caught the new gate. The error-code catalogue is derived
from the route tree on every run and fails in both directions, so a code the
server emits and does not publish is a client that cannot branch on it; the
session-surface guard freezes the short list of files under `src/app` that
resolve a caller without `requireAuth`, and the register route now belongs on
it — `requireAuth` is the wrong gate there twice over, since it would throw at
the anonymous caller the route exists for and this route wants the ABSENCE of a
session.

The code is `auth.already_authenticated` rather than the bare
`already_authenticated`: the catalogue's own header rules that new codes are
dot.case, and it sits beside `auth.mfa.code_invalid` in the same family. The
OpenAPI document renders the catalogue into the ErrorEnvelope description, so
it is regenerated.
2026-09-11 09:51:32 +02:00
Marc-André Bombeck 3fc06d0b10 test(e2e): the connect journey covers the fall-through too
Running the two cases in parallel let each walk the other's account state, so
they go serial like the sibling journeys. The second case also asserted the
wrong thing: with a Q2 area answered as well, an already-delivering source
correctly falls through to `log-reading` — that IS the fix. Split into three:
nothing connected, a connection with somewhere to fall to, and a connection
that is the only task the answers name.
2026-09-11 09:47:43 +02:00
Marc-André Bombeck 0290554d36 test(e2e): an invite link registers from a cookie-less window
Every layer of the invite chain had unit coverage and the chain was broken
anyway: the proxy test proved the edge admits the landing, the page test proved
it redirects onto the registration form, and neither could see that the
redirect arrives in an RSC flight payload after the shell has already flushed.
The operator's path — mint an invitation, open the URL in a private window,
reach a usable form — was exercised by no spec at any level.

The new spec mints a real invitation in the admin UI and opens it twice: once
in a genuinely cookie-less context, asserting both where the visitor lands and
that they never passed through the sign-in page, and once in the admin's own
window, asserting the explaining panel and that the admin's session survives. A
third case pins the server half through the wire. Against `origin/main` all
three fail — the anonymous visitor lands on `/auth/login`, the panel does not
exist, and the registration POST answers 201 over a live session.

The minted URL gains a test id so the spec reads it from its own element
rather than from the first paragraph in the ticket.
2026-09-11 09:40:26 +02:00
Marc-André Bombeck e5cfdc9fdf test(e2e): the AI offer follows the shared provider's health
Two runs on one account, and the only difference between them is a row in
provider_health: a success inside the freshness window brings the offer and
the tap leaves an active ai_full receipt; a dead credential inside its
cooldown brings 'not answering' and no button at all.

The seed is instance-wide because the projection is - a fresh account's own
ledger is empty by construction - so the spec runs serially and puts the
operator key and the ledger rows back afterwards. The offer path asserts
that no provider was called: the flow reads a projection and never probes.

Verified as a real check, not a tautology: moving the seeded success from 30
minutes to 25 hours ago makes the offer case fail.
2026-09-11 09:36:07 +02:00
Marc-André Bombeck bda18f9e2d test(profile): the edge proxy is not a profile submitter
The demo allowlist names a path and a method as data, which reads to the
submitter matcher exactly like a request expression. proxy.ts submits
nothing and has no answer to read, so it is excluded by name.
2026-09-11 09:35:17 +02:00
Marc-André Bombeck dfa5b9a5e0 test(e2e): the connect journey asserts after the status resolves
No journey reached the connect arm: bp answers `manual`, visit answers
`file`, and the child path ends at confirm. The a11y sweep opened the screen
and asserted the connect card was visible — which it was, for the few hundred
milliseconds before the status envelope resolved, so the sweep passed through
the defect rather than catching it.

The new journey answers Q4 with a wearable and asserts only after
`GET /api/integrations/status` has settled: nothing is claimed for an account
with no connection and no result is stamped on its behalf, and a connection
seeded as delivering is acknowledged rather than offered. The a11y sweep now
waits for the tile the screen settled on instead of the frame before it.
2026-09-11 09:31:59 +02:00
Marc-André Bombeck 982c35287d fix(demo): the setup flow's baseline writes are allowlisted
A demo visitor who typed a height on the confirm screen got a 403 and a
generic toast, and the "someone I look after" arm could only take
"Finish without the profile": the profile PUT, the self-context PUT and
the managed-profile POST were missing from the demo allowlist, so only
the empty-form path completed.

All three are added, pinned per method as the rest of the list is. None
of them creates health data.
2026-09-11 09:26:10 +02:00
Marc-André Bombeck a6dedffdb8 fix(onboarding): no dead settings links before completion
Both Settings links on the confirm screen are page routes, and the
onboarding cookie that sends every page route back into the flow is
cleared by the completion this very screen calls. On a first run they
could not go anywhere.

They now render as plain text until the flow has been completed once.
The words stay, because where a setting lives is true either way.
2026-09-11 09:25:28 +02:00
Marc-André Bombeck e9e4a4b44d fix(onboarding): confirm names the modules leaving the nav
The screen listed what the answers switch on and said everything else
stays one click away under Settings, Modules. That is true of
reachability and false of the navigation entry: a module the answers do
not ask for is written off by the completion's derivation.

It now names those modules, read through the registry's own merge rule
and intersected with the map the navigation shows today, so nothing is
claimed about a module the account payload says nothing about.
2026-09-11 09:24:36 +02:00
Marc-André Bombeck 8d81ac0708 fix(onboarding): a connected source is acknowledged, not offered
`chooseFirstResultTask` read the answers alone, so an account whose WHOOP had
been feeding the dashboard for months was sent to go and connect WHOOP. It
takes the delivering sources now; the server still picks from the answers,
having no status in hand, and the screen re-picks once the envelope resolves.

The fall-through stops where there is nowhere to fall to: a person whose one
answer is an already-connected source keeps the step and is told what was
found, rather than ending the flow on no result at all.
2026-09-11 09:22:18 +02:00
Marc-André Bombeck be7fbe37d5 docs(access): the grant card says the invitee needs an account
A sharing invitation carries no link and nothing is e-mailed — it is addressed
to an account that already exists on this instance, and the person accepts it
from inside their own session. The card's input hint already named the account
requirement and left the rest unsaid, so an inviter looking for a link to send
had nothing to find and no reason to stop looking. The card now names where the
invitation turns up, in all seven locales.
2026-09-11 09:16:24 +02:00
Marc-André Bombeck da58d16642 fix(onboarding): the connect step reads the sync verdict
The step decided "connected" from the ledger's `state` and had one piece of
copy for every outcome: "the first sync is in progress". That sentence was
false for a connection delivering for months, and worse for one the person
has to repair — the screen said everything was fine while the grant was gone.

`connectSourceView` resolves the envelope entry into one of four tiles and
says whether the flow may record its result. Only `pending_first_sync` and
`fresh` may; `stale` is acknowledged without being claimed as new; `stalled`,
`failing`, `reauth_required` and `parked` name the state and link at the page
that can change it. An unresolved envelope paints no tile at all.

A source the instance cannot connect — a BYO provider with no client pair, a
shared-app provider with no app — now points at the credentials instead of
offering a "Connect" button onto a card that has none.
2026-09-11 09:15:32 +02:00
Marc-André Bombeck ce3c844a58 fix(auth): an invitation never registers over a live session
`POST /api/auth/register` read no session at all, so a request carrying a valid
`healthlog_session` was handled exactly like an anonymous one and the
unconditional `createSession` at the end replaced the caller's cookie with the
new account's. An admin opening his own invite link to check it was signed out
of his admin account and into a stranger's onboarding, his previous session row
still valid server-side and no longer reachable.

The route now resolves the optional session first and refuses with 409
`already_authenticated`, before the rate-limit bucket is spent and before the
invitation is consumed, so a signed-in visitor neither burns a use nor locks
the door for the person the invitation is for. The register page renders an
explaining panel with a sign-out button in place of the form, so nobody has to
fill three inputs to discover the refusal; signing out returns to the same
invitation rather than to the login screen. Copy lands in all seven locales.
2026-09-11 09:15:11 +02:00
Marc-André Bombeck 77a09a5080 fix(onboarding): an empty multi-select is not an answer
Q2, Q4 and Q6 let "Weiter" write an answer with nothing ticked. On Q2
that answered "no areas", which is what switched every optional module
off for somebody whose click meant "no preference"; on Q6 it marked the
step done while recording no unit at all.

An empty selection now yields no body, so the primary button is dead
until something is chosen. Skip stays the way past, and Skip is the
conservative derivation the completion route already applies.
2026-09-11 09:14:34 +02:00
Marc-André Bombeck 74ae672373 feat(onboarding): the done screen offers the provider only when it works
The card read one field and made two claims from it: that insights work,
and that no setup is needed. The first was a presence read - it knew a key
existed, never that it answered - and the second skipped the consent
receipt the egress actually needs, so the person who believed the note met
a consent.ai.required refusal on their first briefing.

Four branches now, one button. The offer appears only when the server says
the shared provider is known to work and nothing forbids it, and the tap
posts the affirmative web intent. An unhealthy or unknown verdict paints
'not answering' with no button. When the offer is refused for a reason that
is not health, the panel stays neutral rather than blaming the provider.
The note survives for the one state it was ever true of: consent on file.

The sample, the ladder, the 'fully useful without' line and the setup link
are identical in every branch. Copy in all seven locales.
2026-09-11 09:13:23 +02:00
Marc-André Bombeck 0e79b195b0 fix(onboarding): a failed completion leaves confirm usable
The baseline form raised its pending flag and lowered it on only two of
three exits; the success path went through the confirm screen's finish(),
which caught its own error and returned normally. A failed completion
therefore disabled both buttons until the page was reloaded.

The reset now lives in a finally, finish() rethrows so the form sees the
failure, and the two managed-arm buttons report it themselves. The step
passes before the completion run off a ledger ref, so a retry after a
failure midway no longer re-sends a step the ledger already carries.
2026-09-11 09:12:36 +02:00
Marc-André Bombeck cfca84a294 fix(auth): the invite landing redirects at the edge, no shell, no race
The proxy now answers `/invite/<token>` with a real 307 onto
`/auth/register?invite=…` (or a bare `/auth/register` when the segment fails
the `hlv_` shape gate), carrying the baseline transport headers plus the
no-store / noindex / no-referrer hardening the token-in-path surface has had
since v1.17.0.

Before this the redirect came from the landing page, which Next delivers in the
RSC flight payload rather than as a server 307 — the shell mounted first and
raced it. At the edge there is no HTML and no hydration, so there is nothing to
race. The page stays in place as the fallback for any path that bypasses the
proxy matcher.
2026-09-11 09:09:46 +02:00
Marc-André Bombeck 0bdbf009f6 fix(integrations): a provider with no history is "unknown"
The ledger's synthetic "no row" snapshot returned `state: "connected"`, so
the field's default meant the opposite of its name: every account that had
never connected a provider published `connected` on the status envelope.
Settings survived it by never reading `state`; the setup flow's connect step
did read it, and told brand-new accounts their wearable was connected.

The snapshot now reads `unknown`. `syncHealth.verdict` is unchanged and stays
the only liveness truth; the managed-record projection maps `unknown` onto
`disconnected` the way it already maps a missing connection.
2026-09-11 09:08:38 +02:00
Marc-André Bombeck 753438a329 fix(auth): the invite landing is public in the client shell too
`/invite/<token>` has been public in `proxy.ts` since v1.17.0 and was never
added to the client-side gate in `auth-shell.tsx`. Next streams the shell
before the landing page's RSC redirect resolves, so the shell classified the
route as protected, `/api/auth/me` answered 401, and `router.replace(
"/auth/login")` won the race — the operator's invite link landed on the sign-in
page it was meant to bypass.

The shell now lists `/invite/`, and lists the `/auth/` family as the prefix the
edge already admits rather than as two individual pages, so a future auth page
cannot inherit the same defect. `PUBLIC_PATHS` is exported from `proxy.ts` so a
new parameterised guard can render the shell at every page prefix the edge lets
through and fail when the two lists drift apart again.
2026-09-11 09:08:07 +02:00
Marc-André Bombeck 0d2bad69e3 feat(ai): the provider status says whether the shared provider fits
GET /api/user/ai-provider gains three additive fields: the instance-wide
health tri-state, whether this caller already holds a receipt, and the one
boolean that answers 'may the shared provider be offered in one tap'.

The offer is the conjunction of five preconditions and every one of them
can only take it away: the operator's provider is the one that would serve
the caller, the health is demonstrably healthy, the assistant master and
coach flags are on, the acting record holds its own credentials, and no
receipt is on file yet. A managed profile is excluded by the positive form
of the policy test - 'not deny' would have let a child's record through.

Nothing here probes a provider; a probe would bill the operator for every
registration on the instance. docs/api/openapi.yaml is regenerated from the
schema source.
2026-09-11 09:07:45 +02:00
Marc-André Bombeck dec5732687 fix(onboarding): checklist queries follow its own setup rule
The card's visibility rule gained a "ran the setup flow" disjunct; the
component's query gate kept restating the older half of it. A wizard-run
account past five readings therefore saw a permanent checklist whose
medication, data-source, notification and insight rows were frozen at
"not done", because every supporting query was disabled.

Both places now read one exported predicate, isStillInSetup, which also
keeps the rows live for a restarted run nobody finished.
2026-09-11 09:07:41 +02:00
Marc-André Bombeck 82b7c11b05 feat(ai): a tri-state health of the instance's shared provider, fail closed
The done screen promised working insights on the strength of a presence
read. A fresh account's own provider-health ledger is empty by
construction (the table is unique on user + provider), so the evidence has
to be an instance-wide fold. It folds across accounts and returns three
words: no counts, no timestamps, no account can be inferred from it.

Fails closed against the grain of the ledger it reads. There the fail-open
protects a generation already running; here the output is a promise made to
a person, so an empty table, a timeout or a read error is 'unknown', and
'unknown' buys nothing.
2026-09-11 09:04:50 +02:00
Marc-André Bombeck 274923a060 Merge release v1.38.18
The first run asks what you need and switches on exactly that; the five-step wizard and the two columns it wrote are gone.
v1.38.18
2026-09-11 00:09:59 +02:00
Marc-André Bombeck 677019b53b fix(onboarding): let the confirm screen's sex select shrink at 390 px
The string-headroom sweep went red on CI for French at phone width by
two pixels the Mac never showed: the runner's fallback fonts render a
touch wider. The box was the sex select on the confirm screen — its
value is whitespace-nowrap, and a grid item's automatic minimum is its
min-content, so the track was pinned at the padded option's width and
the shell scrolled sideways. Both cells of that row and the trigger
carry min-w-0 now: the track takes the free space and the value
ellipsises, which is the select primitive's own design. Reproduced and
verified locally by narrowing the viewport to 372 px, which stands in
for the wider fonts (336 px of scroll before, none after).

The sweep now names what overflowed: the boxes past the shell's edge,
with tag, slot, classes, text prefix and edges, ride the failure
message and the outside list, so the next red run says what rather
than only that.
2026-09-10 23:50:30 +02:00
Marc-André Bombeck aba785f9f4 test(e2e): expect the module-off notice on a direct visit, not a redirect
A module page whose switch is off answers with the shared notice that
names the switch and offers the Modules settings, so a bookmark or a
report link explains itself instead of bouncing home. The vaccinations
journey pinned the old redirect and went red for the behaviour the setup
flow introduces on purpose.
2026-09-10 23:24:45 +02:00
Marc-André Bombeck 1abf528925 chore(release): v1.38.18 — the first run asks what you need and switches on exactly that
Bump the version, the service-worker fallback and the OpenAPI document,
and add the CHANGELOG entry for v1.38.18.
2026-09-10 23:09:23 +02:00
Marc-André Bombeck 8ed46eef58 Merge remote-tracking branch 'origin/main' into feat/onboarding-wizard 2026-09-10 20:53:38 +02:00
Marc-André Bombeck 85151ca24e Merge release v1.38.17
A webhook or ntfy target on your own network can be reached again with the operator's say-so, the test button says why a private target was refused, and the second of two close doses can be logged from the card again.
v1.38.17
2026-09-10 20:52:57 +02:00
Marc-André Bombeck 550a7b87d7 fix(medications): measure a row's nearest occurrence across sibling schedules
The nearest test enumerated only the schedule under test, and production
rows carry no schedule identity. On a medication composed of sibling
schedule rows (08:00 daily plus 12:00 daily, the wizard's compose shape) a
row snapped exactly to A's 08:00 was also asked about B's 12:00, and B's
own occurrences alone never showed that A's 08:00 is nearer: next-due, the
display due and the compliance cycle all answered tomorrow's 08:00 at 09:00
local, the same swallow one level up.

The neighbour set is now the union of every schedule's occurrences within
the radius, memoised per row, since it depends on the row alone and a
predicate asks about many slots. The rolling short-circuit still looks at
the schedule under test only. The era filter in the next-due predicate
compared a field no caller ever populated, so it is gone along with the
test that pinned it; the era floor lives in the overdue search's band
range and in the forward walk's start at now, as it always did in
production.

Tests: sibling schedules in both next-due and the compliance cycle, the
exact tie between two slots, the 25-hour fall-back day (a pair across the
repeated hour, and a row anchored in the old offset), and the ad-hoc
epsilon case now stands on the ad-hoc row alone.
2026-09-10 20:34:36 +02:00
Marc-André Bombeck 95140e5aa7 fix(medications): resolve one occurrence per intake row, the nearest
A taken, skipped or auto-missed row counted as resolving every occurrence
within six hours of its anchor. The radius was sized for the twice-daily
12 h gap, so on any schedule whose neighbouring doses sit closer than that
(19:49 and 23:59, 08:00 and 12:00, every four hours) the first dose's row
resolved the second occurrence too: the next-due walk jumped to tomorrow,
the card offered tomorrow's first dose, and a skip pressed next landed
there. The second dose could not be logged from the card at all.

Reproduced with a Europe/Berlin account on 19:49 + 23:59: the 19:49 take
left the 23:59 row pending and the skip was written to tomorrow's 19:49.
The compliance-journey e2e spec exposed the same thing in every run that
started after noon, when its twin slot is 23:59.

Rule, in slot-resolution.ts and shared by the list's next-due, the card's
overdue search and the compliance current-cycle descriptor: a row resolves
exactly one occurrence, the one nearest its anchor, and only within the
drift radius. An exact match short-circuits; a drifted anchor is measured
against every occurrence the schedule emits within the radius and loses to
any nearer one (ties go to the earlier occurrence), so the effective reach
is min(radius, half the gap to the neighbour) and a sibling can never be
swallowed. Rolling cadences skip the nearest test, their occurrences are
at least a day apart. Ad-hoc rows keep the one-minute epsilon, now in the
cycle descriptor as well, so the card's due slot and its cycle state agree.

The drift tolerance itself stays: it covers a row whose anchor sits off the
canonical instant, a pre-snap row or an instant that moved under it across
a DST shift, which is what the radius was introduced for in v1.15.10.
2026-09-10 20:17:38 +02:00
Marc-André Bombeck 80171436e3 chore(release): v1.38.17 — a private notification target can be listed by the operator
Bump the version, the service-worker fallback and the OpenAPI document,
and add the CHANGELOG entry for v1.38.17.
2026-09-10 19:27:49 +02:00
Marc-André Bombeck 2380a4e7e6 docs(api): regenerate the contract for the loopback wording
The four notification routes describe private_origin_not_grantable as
link-local, metadata or unspecified only; the generated YAML follows.

Refs #947
2026-09-10 19:18:16 +02:00
Marc-André Bombeck 08e66a464b Run the child journey's two tests one after the other
Both reset and walk the one account; on two workers each undid the
other's setup mid-flow, which is the reason the two sweeps were already
serial.
2026-09-10 19:18:02 +02:00
Marc-André Bombeck 94bed12edc fix(egress): keep loopback grantable under an exact operator origin
A Nightscout self-host on host networking with
NIGHTSCOUT_PRIVATE_ORIGINS=http://localhost:1337 worked before and must
keep working unchanged; an operator who lists an exact loopback origin
with its port has made the same deliberate decision as one who lists a
LAN origin. The never-grantable floor is now the unspecified address,
link-local and the metadata endpoint only. Loopback (127/8, ::1,
localhost) is grantable when listed exactly, still resolved and pinned
at connect time with redirects forbidden, and still refused when not
listed.

::1 is answered before the embedded-IPv4 unwrap, which read it as the
IPv4-compatible spelling of 0.0.0.1 and refused it as unspecified. The
pinned Nightscout transport test runs the real floor again. Each refused
range keeps its own test; loopback moves to the grantable-when-listed
set with a test that an unlisted loopback is still refused, and a
Nightscout grant of http://localhost:1337 is asserted to parse.

Refs #947
2026-09-10 19:08:04 +02:00
Marc-André Bombeck a2e407909c Never derive answers given for somebody else onto the guardian
"Finish without the profile" completed the flow with no record id, and
the route then derived the child's answers onto the guardian's own
record. The server now decides: answers given for somebody else with no
record to apply them to complete and latch the flow without deriving or
seeding anything, whatever the client sent. The checklist carries the
profile instead and reads none of those answers as the guardian's own.

A record id the caller does not guard answers 404 like every
id-addressed managed-profile route, one answer for missing, not managed
and not theirs alike; the contract documents the field, the 404 and the
422 text. Pinned by two integration cases and a second child journey.
2026-09-10 18:58:21 +02:00
Marc-André Bombeck 9c306f04e7 fix(notifications): name the never-grantable refusal and redact the grant log
A loopback, link-local or metadata target answered
private_origin_not_approved with a message telling the user to have the
operator list it, and the parser refuses such an entry forever: a dead
end the route test even pinned. Those targets now answer
private_origin_not_grantable (catalogued, on all four routes, translated
in seven locales) with a message that says no grant can open them and
to use the relay's LAN address. Both senders carry the reason into the
outcome so the test button forwards the same code.

The malformed-entry warning printed the entry verbatim, which is where
a pasted token or userinfo sits; the parser now hands callers scheme
and host only, plus the reason in words. The Nightscout list throws a
named error with that redacted entry and reason, and the connect route
answers it as a 422 with the message instead of a bare 500, so a
self-host that listed http://localhost:1337 reads what to change.

The two settings cards translate a coded save refusal like the test
button does; the docs and the changelog name the sub-host and sibling-
port narrowing; the dispatcher floor tests assert the policy marker so
a real NXDOMAIN cannot pass for a refusal.

Refs #947
2026-09-10 18:45:57 +02:00
Marc-André Bombeck 55ff6df205 Merge remote-tracking branch 'origin/main' into feat/onboarding-wizard
# Conflicts:
#	docs/api/openapi.yaml
#	src/lib/openapi/error-codes.ts
2026-09-10 18:20:07 +02:00