Commit Graph
6474 Commits
Author SHA1 Message Date
Marc-André Bombeck fafb5cdc08 feat(notifications): add a Gotify payload format to the webhook
Gotify's POST /message types priority as an integer, so the generic body, which sends the word, was refused with a 400 before the token was ever the question. The webhook card now offers a payload format: generic, which stays byte for byte what it was, and gotify, which sends title, message, an integer priority and extras.

Priorities follow the Gotify Android client's channel thresholds: 5 for routine events (audible), 8 for medication reminders (the lowest heads-up value), 10 for urgent events. The extras declare plain text and carry the event name, masked to reminder in discreet mode as the generic body does.

The choice lives in the encrypted channel config, so no migration: an absent format is generic, only gotify is written, and a save that omits the field keeps the stored choice. GET returns the resolved value. The sender comment no longer suggests an Authorization Bearer header for Gotify, which takes X-Gotify-Key or a token query parameter.

Refs #947
2026-09-13 14:19:48 +02:00
Marc-André Bombeck 92606f6409 feat(settings): show the status and reply of a failed channel test
The test-connection callout now appends the HTTP status or SMTP reply code the route reports, and shows what the relay answered on a line beneath it. The reply is rendered as a React text child, so markup in it is shown as characters.

Adds the upstream_rejected message in all seven locales.

Refs #947
2026-09-13 14:13:55 +02:00
Marc-André Bombeck f6a890df8a fix(notifications): answer a failed channel test with its cause
The webhook, ntfy and email test routes turned every delivery failure into a bare 500, so the settings card could only say "Test failed" while the server knew the relay had answered 400. A failure at the other end, or on the way to it, is now a 502 with meta.errorCode, the upstream HTTP status or SMTP reply code, and for webhook and ntfy what the relay answered when that is short and holds no credential. The codes reuse the test card's vocabulary; upstream_rejected is new, for a relay that accepted the connection and refused the payload. The private-origin 422 is unchanged, and only a failure the server cannot name stays a 500.

The guard that pinned the bare 500 now asserts the opposite, with the reason in its name.

The admin fan-out test reports the same code and status per channel, and tests the webhook and email channels, which it used to list as an unknown channel type.

Refs #947
2026-09-13 14:10:35 +02:00
Marc-André Bombeck 8c345f600e feat(notifications): keep what the relay answered on a failed send
The webhook and ntfy senders now carry up to 200 characters of a non-2xx response body on the send outcome, with control and formatting characters removed. The body is dropped when it looks like a credential or echoes the header value, auth token, topic or a URL token. Nothing stores it; the ledger row and channel state keep the reason code as before.

Transport faults without a status now say whether they were a timeout or a refused connection, and the email sender maps SMTP failures into the same vocabulary with the reply code. Both exist so the test routes can answer with the cause instead of a bare 500.

Refs #947
2026-09-13 14:04:40 +02:00
Marc-André Bombeck 15f0e499f9 refactor: share the secret-shape matcher from one leaf
The idempotency cache refuses to persist a body carrying a HealthLog token or an AI provider key. The notification test routes are about to refuse echoing such text too, so the pattern moves to src/lib/secret-shape.ts instead of being written a second time. Behaviour of the cache is unchanged.
2026-09-13 13:56:58 +02:00
Marc-André Bombeck 75760b606a Merge pull request #962 from MBombeck/docs/compose-run-migrations
Run the manual migration inside the stack
2026-09-13 13:21:49 +02:00
Marc-André Bombeck 6a5ad9d9dc docs(ops): run the manual migration inside the stack
The update runbook ran migrations with a plain docker run and an
.env.production file. No setup guide creates that file, and a plain
docker run starts outside the Compose network, where the database's
service name does not resolve, so the step waited thirty seconds and
gave up with a connection error.

It uses docker compose run now, which starts the one-off container in
the stack with the app's own environment. Refs #961
2026-09-13 13:03:41 +02:00
Marc-André Bombeck d1bab391fc Merge release v1.38.20
Signing in no longer tells anybody which addresses have an account here.
v1.38.20
2026-09-11 16:41:29 +02:00
Marc-André Bombeck 0568c76076 chore(release): v1.38.20 — signing in no longer says who has an account here
Bump the version, the service-worker fallback and the OpenAPI document,
and add the CHANGELOG entry for v1.38.20.
2026-09-11 16:15:58 +02:00
Marc-André Bombeck 9bf78e32d6 Merge remote-tracking branch 'origin/main' into fix/close-account-enumeration 2026-09-11 16:01:25 +02:00
Marc-André Bombeck 660d6b39be Merge release v1.38.19
The first run tells you what is actually true about your account, an invitation link behaves, and the AI you ask a question to has budget left when you ask it.
v1.38.19
2026-09-11 14:56:05 +02:00
Marc-André Bombeck 93874af99b fix(api): record the retirement against the release that carries it
v1.38.19 was cut before this change and still answers on the path, so
naming it as the version that removed the route would be wrong in the
published contract from the day it appears.
2026-09-11 14:39:30 +02:00
Marc-André Bombeck 625763982b Narrow a spent email budget to the field, and record both refusals
A refused address change returned before the update was built, so every
other field in the same request went with it. The settings form posts
the whole profile at once, so somebody who had spent the budget could
not save a timezone or a height for an hour and was told it was about
email addresses. The address is now dropped and the rest of the save
lands, reported through the existing rejectedFields key with the code
`rate_limited`; the 429 is kept for a request that asked for nothing
else, where a partial success would be a lie.

The comment claiming an audit row beside each question was inverted:
both refusals returned before the row is written, so only the answers
that told the caller nothing were being recorded. The two answers worth
detecting write their own rows now.

The stored address is normalised before the "has it changed" comparison.
Registration writes it as typed, so an account created with capitals did
not match its own address and paid for its first ordinary save.
2026-09-11 14:36:58 +02:00
Marc-André Bombeck 3ace3aad35 Answer 410 for the removed account-discovery path
The path stood in the published contract, so a caller outside this
repository can hold a reference to it whether or not we can name one. A
bare 404 reads as a broken deploy or a misrouted proxy, which is exactly
the reading that kept a client retrying a removed path for twelve days
once before. Registering the retirement makes the edge answer 410 with
the removing version and no replacement, and republishes the path as
gone rather than erasing it from the document.
2026-09-11 14:36:47 +02:00
Marc-André Bombeck 6a4e1f61c8 chore(release): v1.38.19 — the setup says what is true, and the AI has budget left when you ask
Bump the version, the service-worker fallback and the OpenAPI document,
and add the CHANGELOG entry for v1.38.19.
2026-09-11 14:36:38 +02:00
Marc-André Bombeck 10d6dc8099 Guard the dummy mint, not only the dummy verification
The dummy hash was awaited as an argument to the verifier, so a rejected
mint threw before the verifier was reached and before the catch on it
could apply. The promise was created once at import, and a rejected
promise stays rejected, so one failed mint under memory pressure would
have made every unknown identifier answer 500 while a real account
answered 401 — a cleaner oracle than the timing gap this closes, and
passkey-only accounts locked out of the password form until restart.

The mint is now awaited inside the guarded region, is lazy rather than
an import-time cost in every process that touches this module, and is
dropped on failure so the next refusal mints again instead of inheriting
a cached rejection. A failure leaves a log line; the answer is false
either way. The tests cover a rejecting mint, not only a rejecting
verify.
2026-09-11 14:36:27 +02:00
Marc-André Bombeck 80426697db Record why registration checks duplicates before hashing
With the discovery endpoint gone, this is the remaining anonymous path
that answers whether an address is registered. The 409 already gives
that away, so the work asymmetry ahead of it costs nothing today. It
would become the whole answer the moment somebody unified the status
code without moving the check after the hash, so the ordering now says
it is load-bearing.
2026-09-11 14:36:19 +02:00
Marc-André Bombeck 4460047910 Merge the coach cap counting only the tokens the operator paid for
Background insights can no longer use up the day before somebody asks a question; a fifth of the daily limit is held back for what a person does by hand.
2026-09-11 14:18:21 +02:00
Marc-André Bombeck a602437649 Merge the ledger wait
The test slept fifty milliseconds and read; it waits for the rows now.
2026-09-11 14:13:06 +02:00
Marc-André Bombeck fe420f76d8 Cap the profile email-address conflict answer
Changing the profile address asks whether another account already holds
it, and the answer is 409 versus 200 — an existence check over every
address on the instance, open to any signed-in caller. Neither route
that reaches it had a ceiling of any kind and the API wrapper supplies
no default, so the only bound on a sweep was the network.

Both routes now share one per-account bucket, ten an hour, charged
before the question is asked so a refused caller learns nothing. Only a
request whose address actually differs from the one on file is counted,
so re-saving a settings form that carries the unchanged address stays
free. The refusal is the standard 429 with Retry-After and the
X-RateLimit triple, carries `profile.update.emailRateLimited`, and is
published on both operations.
2026-09-11 14:05:25 +02:00
Marc-André Bombeck 6a5fb338b1 Make every login refusal cost the same verification
Sign-in returned the moment it found no account, or an account carrying
no password hash, before it reached the verifier. An account that did
have a password paid a full Argon2id verification at 19 MiB and t=2. The
three refusals answered with the identical 401 body and three very
different durations, so the clock told an anonymous caller whether an
address is registered and whether it carries a password.

`verifyPasswordOrDummy` verifies against a stand-in hash minted once per
process at the same cost parameters real hashes use, and returns false
whenever there is nothing to verify, so the passkey-only account takes
the same path as one with a password. The dummy arm discards its own
verdict and swallows a throw, so no outcome there can be read as a
success or answer with a different status.
2026-09-11 13:59:42 +02:00
Marc-André Bombeck 6ef139c7b2 Remove the anonymous account-discovery endpoint
`POST /api/auth/check-user` answered, for any typed identifier and with
no credential at all, whether an account exists and whether it carries a
passkey or a password. Its header called that the intended contract with
the native client; it is not one any more. The native client never calls
it, and no web surface in this repository does either.

Removed without a replacement: the route, its two test files, the
OpenAPI operation and its two schemas, the surface's mention in the
rate-limiter doc comment, the bucket expectation in the auth-surface
rate-limit test, and the login route's comment pointing at it. No stub,
no deprecation shim, no 410 arm — there is no caller to keep.
2026-09-11 13:56:49 +02:00
Marc-André Bombeck 49acba440a Merge remote-tracking branch 'origin/main' into test/settle-ledger-poll 2026-09-11 13:53:31 +02:00
Marc-André Bombeck 585b55fd49 Merge the setup flow saying only what the account's state supports
An invitation link no longer offers to register while you are signed in, the connect step says what is true about a provider, and the AI offer waits until the provider has actually answered.
2026-09-11 13:53:19 +02:00
Marc-André Bombeck 83c54a6b79 test(integration): wait for the ledger row, not for fifty milliseconds
The delivery ledger is written without awaiting the result, so the row
appears some time after the dispatch returns. The test slept a flat fifty
milliseconds and then read, which holds on a warm machine and loses on a
loaded runner: the assertion reported an empty ledger as though the
dispatch had not recorded the skip.

It polls for the rows now, up to five seconds, and fails with a message
naming how many it found. The claim is that the row is written, which is
what the code promises; that it is written within fifty milliseconds is
not.
2026-09-11 13:43:19 +02:00
Marc-André Bombeck 7436a0fcba Merge main — the test-infrastructure fixes from the shared suite
Brings in the truncate retry, the socket fix for the record-fence spec that
failed this branch's second e2e shard, and the corrected absence assertions.
2026-09-11 13:22:43 +02:00
Marc-André Bombeck 92ba0513d2 Merge three harness defects that made checks lie
A truncate that lost a deadlock, a spec reading through a stale pooled socket, and fifteen absence claims asserted before anything had rendered.
2026-09-11 13:21:49 +02:00
Marc-André Bombeck 9cefaecf94 test(e2e): anchor absence claims on a rendered surface
The protected shell renders its hydration gate for every child until
/api/auth/me resolves, so after a goto, a reload or an exit click nothing
exists yet: no nav, no top bar, no banner, no page body. Fifteen absence
assertions across twelve specs were asserted inside that window. Each was
true because the surface had not rendered, and each would have stayed green
if the thing it forbids came back.

Every site now waits for a positive marker of the loaded state first: the
document sheet's footer download action, the settled integrations card
rather than the family attribute its skeleton shares, the top bar after an
exit, the page's own heading, the delegate's own marker, the Coach FAB. Two
skeleton-absence waits are replaced by waits on the content that follows.

delegated-writes had a different cause: it asserted the bulk selection bar
absent although no row was ever selected, so it passed for an owner too. The
gate is one level earlier - below MANAGE no row carries a checkbox at all -
so the test asserts that instead, with the bar and the row delete kept as
backstops.

No sleeps added, no timeouts raised, no claim weakened.
2026-09-11 13:00:56 +02:00
Marc-André Bombeck 82d4a32726 fix(onboarding): the path CTA breaks onto two lines at phone width
The connect step's new attention and credentials tiles link to the
integrations page with a label that names a path — "Open Settings →
Integrations". The button base is `whitespace-nowrap` with a fixed height and
`shrink-0`, so at 390 px the German and French renderings could neither shrink
nor wrap: the control ran 27 px past the viewport. The pre-existing connect
card was unaffected, its label being a short verb phrase; the path label and
the tiles that carry it are new, which is why the sweep passed before.

The CTA is bounded to its card, allowed to break between the words of the
path, and allowed to grow for the second line, with the 44 px tap target kept.
No copy moved, so the seven catalogues stay as they are.
2026-09-11 12:57:10 +02:00
Marc-André Bombeck bcef5aeeee fix(ai): hold a reserve so the chat is never locked out by jobs
The background ceiling applied only when the operator funded the chain, so on a
chain the operator does not pay for the automatic generators and the chat shared
one undivided ceiling and the first could still take all of it. That is the
configuration the recommended remedy for the original lockout produces — put the
personal Codex account first — so the guarantee was missing on exactly the setup
it was written for.

The ceiling is now expressed as what it is meant to protect. Background work may
run until the day's spend would leave less than a fixed reserve for whoever is
waiting on an answer, on both cost owners. A reserve and a quota look alike at
the boundary and mean opposite things in the middle: a quota stops useful
generation on a day nobody was ever going to interrupt, while a reserve costs it
nothing until the moment it would take the last of the day. An account
generating 1.45 million tokens of insights on its own plan keeps every one of
them and the chat still holds 400 000.

INTERACTIVE_RESERVE_SHARE replaces JOB_SURFACE_SHARE. The old name described a
quota over the jobs rather than a floor under the person waiting, and a name
that states the opposite of the intent is what put the wrong comparison in this
ledger twice already.

The fraction is a default an operator will want to change. The setting and its
documentation are separate work.
2026-09-11 12:55:52 +02:00
Marc-André Bombeck a889f4b1e2 test(e2e): read the session back through the page, not the shared pool
The offline fence spec already sends its account switch with the page's
own fetch, because every Playwright API context in the runner shares one
keep-alive agent and can be handed a socket the server closed on its idle
timeout. The read on the next line still went through that agent, and it
failed with read ECONNRESET on all three attempts, which fails the run
because CI treats a flaky test as a failure on purpose.

It reads through the page now, like the write above it. The two remaining
calls on the shared context sit in finally blocks that swallow their
errors, so they cannot fail a test.
2026-09-11 12:43:30 +02:00
Marc-André Bombeck 42b9aefdfd test(integration): retry a truncate that lost a deadlock
The suite wipes the database between files with one TRUNCATE, which needs
the strictest lock on every table at the same time. The server writes in
thirty-nine places without awaiting the result, so one of those can land
after the test that caused it has returned, and Postgres then picks the
truncate as the deadlock victim. It failed an unrelated file twice in two
days and passed on the rerun both times.

The truncate is retried up to four times, on that one error and no other,
with a short back-off. Three cases hold it there: a deadlock that clears,
an error that is not a deadlock and must not be retried at all, and a
deadlock that never clears and must still fail. All three go red without
the retry.
2026-09-11 12:38:31 +02:00
Marc-André Bombeck b565edc48d docs(ai): drop planning vocabulary from the ledger comments
CLAUDE.md forbids planning shorthand in committed artefacts, and this branch
introduced 74 lines of it across source, tests, a schema field and two
migrations. Someone reading the migration SQL in a year has no way to resolve a
wave letter or a round number, and a comment that points at something outside
the repository names no reason at all.

Three passes. A version followed by a parenthetical keeps the version and loses
the parenthetical, because the version says since when something holds. A
sentence that leaned on the vocabulary to carry its meaning is rewritten to
state the reason directly: the arrival reservation now says it was refused over
money the operator never spent, rather than naming the defect a wave existed to
remove. Three test titles carried the marker in the name itself and are renamed
to what they pin.

Comments only. No behaviour, no renames beyond those three titles.
2026-09-11 12:37:54 +02:00
Marc-André Bombeck 45599c527e fix(demo): the profile route writes only the baseline fields in the demo
Allowlisting `PUT /api/auth/profile` so the setup flow's baseline step could
finish also handed every visitor the shared demo account's contact email, its
display name, its full name and its insurer fields: the allowlist admits a
path and a method, and every field the handler behind it writes comes with
them. The justification beside the entry named three fields; the entry
admitted eleven.

The write is narrowed on the server, in `applyProfileUpdate`, to height, date
of birth and sex while `DEMO_MODE` is set — not in the client, which is the
party the allowlist exists to distrust. It happens ahead of the email conflict
check, which would otherwise answer "is this address registered here?" to
anybody who can open the demo. The display-name box goes read-only there, as
the anamnesis card already is, so nothing accepts input it will discard.

The entry's comment now lists what the handler writes and what is admitted.
2026-09-11 12:22:47 +02:00
Marc-André Bombeck 655eafa757 Merge internal markers out of six published API descriptions
Somebody reading the API document has no way to know what B1 or B6 mean.
2026-09-11 11:40:53 +02:00
Marc-André Bombeck ceefbc5571 fix(onboarding): a receipt on file is not evidence the provider works
The consent branch of the done screen's AI panel painted "insights work for
you right now" on the strength of the receipt alone, reading no health at all.
A person who had consented — including one who had just tapped the offer — was
told a dead provider was serving them. That is the claim-nobody-measured
defect closed at the offer end of the same panel, still open at the other.

The positive sentence now needs a healthy verdict as well. A consented account
on a provider that failed reads the same "not answering" note as anybody else,
and on an unmeasured one the panel claims nothing. The panel's four variants
are pinned across all three health states in every arm.
2026-09-11 11:38:31 +02:00
Marc-André Bombeck 87cf0dc215 docs(api): drop the internal markers from six published descriptions
Six operation descriptions in the API document opened with a version and
a bracketed letter-number code from the notes the work was planned in.
A client author reading the document has no way to know what B1 or B6
mean, and they mean nothing outside those notes. The version stays,
since it says when the operation appeared.
2026-09-11 11:23:28 +02:00
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 71305338bb Merge release entries in the reader's language
Every release entry since 1.35.2 rewritten so a reader can tell what changed for them.
2026-09-11 11:11:26 +02:00
Marc-André Bombeck c66bcbc295 feat(admin): the AI card shows the day's spend split by who paid
The provider-health route answered a spendToday payload that nothing rendered,
which is the pattern the two-ended rule exists to stop. It is also the screen
the operator opens when a surface refuses on budget, and the one mixed number
could not answer the question that raises: a day at 1.24 million tokens is
alarming until the card says that 151 200 of them were the instance's own key
and the rest ran on the users' own plans. The operator ceiling is enforced
against the second figure. Copy lands in all seven locales.
2026-09-11 11:11:17 +02:00
Marc-André Bombeck 7b21840630 fix(ai): the arrival reaction reserves through the shared ledger gate
The arrival-reaction claim carried its own copy of the reservation upsert so
the write could ride the claim transaction. The copy drifted: it learned to
write operator_tokens and kept gating on the day's mixed total, at the halved
background ceiling. On the row the wave was written for, 1.2 million tokens
served by the user's own plan, the reaction was refused all day at half the old
ceiling, reproducing the defect the wave exists to remove on the one surface it
forgot to convert. reserveBudget now takes the transaction client, so both
writes still roll back together and there is no second copy left to drift.

The row also remembers who paid for its reservation. The supersede refund in
data-arrival has no chain to ask and could only reverse the total, so the
operator share of every superseded reservation stayed on the row forever,
walking the operator's ceiling upward against spend that never happened. A
supersede is routine, not an edge case: any second measurement of the same kind
on the same local date before the worker runs. The resume path re-derived the
owner from a chain the health ledger may have reordered since, which leaks the
same way or drains another request's balance.

A structural guard makes the invariant executable. Every INSERT column list,
SET clause and RETURNING list that names one counter must name the other, and
the set of files holding raw ledger SQL is pinned. Both defects above were a
grep away and both would have failed this test.
2026-09-11 11:11:12 +02:00
Marc-André Bombeck 18ed57b867 fix(ai): every surface keeps a ceiling on the day's mixed total
The owner split left an operator-funded chain with no ceiling at all. Its arm
compared operator_tokens, and that counter returns to roughly zero on every
reconcile the user's own plan settled: on the 2026-09-11 evidence the shared
key answered 500 and codex served, so the counter never grew, every job
reservation was admitted forever, and a runaway client loop could write an
unbounded row. A pre-existing concurrency contract caught it from the other
side, two reservations that must not both pass one cap both passing.

reserveBudget now refuses when the owner's counter is over its cap OR the day's
mixed total is over the abuse ceiling, and a background surface gets its share
of that ceiling too, so background spend is bounded on the total and not only
under a counter the operator's key never fills. The surface is a required
argument, so the type checker enumerated every reservation site.

The hop guard learns the same lesson: an operator-funded fallback hop is now
rationed at the calling surface's ceiling rather than the full operator cap. A
job admitted under the job share could previously spend the whole 200 000 once
its chain fell back onto the shared key, which is the original defect reached
by the fallback path.

The job share applies only where the operator pays for the chain. Multiplying
whichever ceiling the chain resolved halved background generation for a
self-hoster on a local model, who costs the operator nothing.

Refusals now name the ceiling they hit and whose it was. A refusal used to emit
the day's mixed total and nothing else, which on an operator refusal is not the
counter that tripped.
2026-09-11 11:11:01 +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