Commit Graph
1998 Commits
Author SHA1 Message Date
Marc-André Bombeck 32eaf7a29e fix(admin): show each channel's cause in the notification test toast
The admin test response carried a code and status per channel that nothing rendered. The toast now lists each failing channel with the translated cause and the HTTP status or SMTP code. The relay reply is no longer part of that response, and the ntfy error no longer echoes the topic, which the sender treats as a secret.

Refs #947
2026-09-13 14:50:33 +02:00
Marc-André Bombeck 6a663dfc27 fix(notifications): record a relay refusal at warn, not error
A 502 from a channel test reports the user's relay or mail setup, not a fault on this server, yet the status alone raised every press to an error-level event. The event builder gains a ceiling for the status-derived level, and the test routes cap it at warn on the 502 path. The 500 for an unnamed failure stays at error, and explicit elevations are unaffected.

The notifications guide notes that a reverse proxy which replaces 5xx pages hides the detail, so the card falls back to Test failed.

Refs #947
2026-09-13 14:50:32 +02:00
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 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 9bf78e32d6 Merge remote-tracking branch 'origin/main' into fix/close-account-enumeration 2026-09-11 16:01:25 +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 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 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 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 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 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 b750e0bc7d feat(admin): the AI readout separates operator-paid tokens
The provider-health endpoint showed delivery health only. When a surface
refuses on budget the operator's next question is which ceiling he hit,
and one mixed token figure cannot answer it: a day at 1.24 M tokens looks
alarming until you see that 151 200 of them ran on the instance's own key
and the rest on the users' plans.

`spendToday` carries the day's key and both counters, aggregated across
accounts. The operator ceiling is enforced against `operatorTokens`, so
that is the figure the readout puts beside the total.
2026-09-11 10:18:26 +02:00
Marc-André Bombeck d3196ee69e fix(ai): background generation cannot spend the interactive day
The automatic generators — status and reference cards, the briefing, the
arrival reaction, the nudge, document summaries and the index backfill —
reserved against the same ceiling as the chat. On the operator's account
that meant 150-330 ledger rows a day, 172 metric generations before
06:42Z, and a ceiling already spent when he opened the chat.

Every background surface now reserves through
`resolveDailyCapFor("job", chain)`: half the cost owner's ceiling, all
job surfaces together. The interactive routes keep the whole ceiling, so
a day of background work can no longer take the user's own away.
2026-09-11 10:09:34 +02:00
Marc-André Bombeck 2c46712e13 fix(ai): the operator cap counts only tokens the operator paid for
The reservation compared the day's TOTAL against whichever ceiling the
chain resolved, and the chain walker's hop guard did the same. On an
account whose background jobs run on the user's own ChatGPT plan, both
saw a total the operator had not paid for: the chat refused at 06:42Z
with 513 539 tokens against the 200 000 operator ceiling, and the
fallback hop to the shared key was closed on the same arithmetic.

An operator-funded chain is now measured against `operator_tokens` and a
user-plan chain keeps its abuse ceiling on the total; the hop guard reads
the operator share too. A day of turns the user's own plan paid for can
no longer lock anyone out of the operator's ceiling.
2026-09-11 09:56:44 +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 badef28e96 feat(ai): the daily ledger records which tokens the operator paid for
`coach_usage` counted one number per (user, day). A turn served by the
user's own ChatGPT plan after the operator's shared key answered 500 was
therefore charged to the operator-cost cap, and the operator's chat was
locked out every morning by background work he pays for himself.

The ledger gains `operator_tokens`: the share of `total_tokens` served by
an operator-funded provider. `reserveBudget` books a reservation to the
owner of the chain's primary and `reconcileSpend` settles it against the
provider that actually served, both inside the same statements as the
total so concurrent requests can never see one counter without the other.
Every call site names its cost owner and its serving hop; the argument is
required, so a new surface cannot inherit the old blind accounting.
2026-09-11 09:42:14 +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 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 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 8ed46eef58 Merge remote-tracking branch 'origin/main' into feat/onboarding-wizard 2026-09-10 20:53:38 +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
Marc-André Bombeck 6adbee581e Apply the review of the setup flow: the child's answers reach the child
"Someone I look after" derived the child's answers onto the guardian's
own record. The completion now takes the managed record the profile
step just created, checks the same access rule the guardian's modules
route applies, and lands the same derivation and dashboard seed on that
record through the same record-keyed write; the guardian's own flow is
stamped complete and latched without touching its map. Three
integration cases pin it, and the child journey reads the child's map
through a real switch — it asserted the guardian's map before, which
pinned the wrong record.

Also from the review: the checklist's visit row read a meta the visits
list never publishes and now counts its body; the first-result tile
read the measurements list as an array and now shows the reading; the
demo allowlist admits the disclaimer and tour writes, with a structural
test over every onboarding route file; restart clears the old first
result, and the screen trusts the ledger before painting a result as
done; the headroom sweep's raw-key check matches the flow's own key
shapes; the demo migration script stops copying the dropped column; the
questions-to-pass rule and the shell's onboarding-path rule are pure and
pinned; the baseline form composes the shared action row; the schema
diff loses the unrelated formatting churn.
2026-09-10 18:19:34 +02:00
Marc-André Bombeck 71175290af Merge remote-tracking branch 'origin/main' into fix/private-notification-origins
# Conflicts:
#	src/app/api/settings/ntfy/route.ts
#	src/app/api/settings/webhook/route.ts
2026-09-10 18:07:55 +02:00
Marc-André Bombeck 984a75d036 fix(api): name the private-origin refusal on webhook and ntfy routes
The save routes evaluate the same policy the senders do. A target on a
private network the operator has not listed is a 422 with
meta.errorCode = private_origin_not_approved and a message that names
NOTIFICATION_PRIVATE_ORIGINS, instead of the generic "Invalid data"; a
listed private address saves although the plain public floor would
have refused it. The settings schemas take the target predicate as a
parameter so the validator module never reads the environment; the
exported constants keep the public floor for the OpenAPI contract.

The test routes forward the sender's refusal as the same 422 and code
instead of a bare 500, so the card can say why the test failed rather
than sending the operator to the wide-event log. A delivery fault is
still a 500. Both test routes and the dispatcher reach the same sender
function, and a structural test holds them there.

The OpenAPI descriptions name the code on all four routes; the test
button's error map gains the code in every locale.

Refs #947
2026-09-10 16:46:13 +02:00
Marc-André Bombeck d91a8dfebe Make the setup flow pass its own journeys and axe sweep
Run against the standalone build, the four journeys and the axe sweep
surfaced five things the unit tier could not see. The completion route
derives only once every question is settled, and Q6 stays pending for
somebody with no unit-bearing area — the confirm screen now passes the
questions the flow never showed before it completes. The dashboard seed
matched a JSON null where the unset column is a SQL NULL, so it never
landed; the retired step route carried the same filter. The auth shell's
onboarding branch matched only the front door, so every question screen
rendered inside the full app chrome with the sidebar. Inline links
relied on colour alone, the step list sat outside any landmark, and the
baseline form brought a second h1 onto the confirm screen. The two
sweeps run serially, since each drives one account through the API.

The integration file gains the seed case that would have caught the
null filter.
2026-09-10 16:38:47 +02:00
Marc-André Bombeck a062610895 Retire the five-step wizard's route, goals and columns
The needs-based flow is the one setup flow now. POST /api/onboarding/step
and the six goal slugs go, and with them the two account columns the
wizard kept — its checkpoint and its goal list — through a migration
that drops them; onboarding_completed_at stays as the first-run
redirect's gate. The one durable effect the goals had, the dashboard
tile order, moves to the completion route: the answers now carry both
halves of Q2, which modules are on and what sits on top, and the seed
keeps the wizard's one contract of writing only while the layout column
is still unset.

The OpenAPI table loses the step path and the completion's description
says what the route does today; the demo-allowlist test names a
mutation that still exists.
2026-09-10 15:32:35 +02:00
Marc-André Bombeck b446911d7d Add the setup flow's done screen
The dashboard is one click away, already ordered, and the checklist
opens on arrival. The tour is an offer here rather than an automatic
launch: pressing it sets the referrer the shell's launcher reads, and
nothing else does. Two record-shaped exits follow the answer to Q1: an
account that said "both" is offered the profile for the person it looks
after, and one that set up a record for somebody else is offered that
record through the same switch the account menu uses, so the first
entry lands where it belongs. The step is marked done on arrival, once.
2026-09-10 15:28:16 +02:00
Marc-André Bombeck 5a805e8d17 Add the setup flow's first-result screen
Exactly one task, by the step machine's fixed priority: connect the
wearable named in Q4, add the first medication, or log one reading for
the first area. The measurement form and the medication wizard render
inline; a connection goes through the integrations page, because the
OAuth handshake cannot run inside this screen, and the screen watches
the status envelope until it comes back connected. On completion the
screen shows what the task produced — the reading on its tile, the
medication with its next dose, the connection with its first sync — and
records the result on the ledger with the server's own instant.

The offer is not recorded on arrival: somebody who leaves mid-task
resumes here rather than on the done screen.
2026-09-10 15:24:32 +02:00
Marc-André Bombeck 58bc91a0a3 Add the setup flow's confirm screen
What the answers switch on is read off the registry's derivation and
never computed here, so the list the person confirms is the list the
completion route writes. Units, language and time zone are shown once
beside it, changeable in Settings, never asked again.

The profile half keeps the baseline form and its per-field refusals:
the fields are seeded from the account so a value it holds is confirmed
rather than typed again, and a refused field still stops the flow. For
"someone I look after" the managed-profile form takes the profile's
place, because that record is created here, after the questions; an
account without a second factor can finish without it and create it
later under Settings.

Confirming posts the completion, passes the first-result step outright
when there is no task to offer, and goes where the step machine says.
2026-09-10 15:19:48 +02:00
Marc-André Bombeck 7b279aec72 Add the six question screens of the setup flow
One question per screen, Q1 to Q6, each a chip set with a real radio or
checkbox inside every chip so the browser owns grouping and keyboard
movement, and a focus ring the five-step picker's hidden inputs never
had. The selection is pre-filled from the stored answers, "Next"
persists it through the answers route and navigates from the state the
server hands back, and Q1 is the one question without a skip.

The questions are data (which step, which answers, one or many, may it
be passed) with two pure translations beside them — stored answers into
a selection, a selection into the strict body — so the wire never sees
a chip this file did not offer. Q6 asks only about the units the ticked
areas need and sends nothing for a value the account already had.

The step page guards every URL with the step machine and stays under
/onboarding/, which is what the first-run redirect lets through.
2026-09-10 15:15:24 +02:00
Marc-André Bombeck 1c2e7c29de Add the setup flow's welcome screen and shell
The front door reads the same ledger every screen reads: a record that
never entered the flow sees the welcome, one mid-way is sent to the step
it owes, a finished one is offered the questions again. The shell prints
the counter and an ordered step list off the step machine, so it shows
exactly the screens this flow has, and every screen moves focus to its
heading on arrival.

"Skip for now" is a real exit — it stamps the account past the first-run
redirect and opens the checklist on the dashboard — and the one-time
medical disclaimer now gates it as well as "Set up", because a skip that
reached the dashboard without it would be a way round the acknowledgment.

The five-step wizard's welcome carousel, goal picker and source grid go
with it; the numbered step route follows in the next commit.
2026-09-10 15:10:50 +02:00
Marc-André Bombeck 12d6aa3078 Merge the scoped-grant fences, the delegate controls that ask their own section and the published switch refusals 2026-09-10 12:29:09 +02:00
Marc-André Bombeck 83b7cd223a docs(sharing): the booster needs two sections, and the guide promised it at one level
The write-level row listed "a vaccination dose and the booster reminder it
suggests" three paragraphs under a section table that puts the vaccination
record in Health background. Read together, that tells a self-hoster a
health-background WRITE grant can plan a booster — which the route beside it
now refuses, because the reminder it mints lives under Readings. The row lists
the dose alone, and the exceptions paragraph, which is where a reader looks for
exactly this, carries the two-section case and says the dose logs either way.

With it, the small things the same pass turned up:

The cycle-insights fixture asserted a property it could not show. Its docblock
claims the cycle-native answer survives the fence, and it seeded no symptom at
all, so `symptomPatterns` was empty in every leg and a regression that emptied
it along with the foreign columns would have passed green. One catalogue
symptom on the luteal days, asserted on the owner leg and on the cycle-only
one.

The immunization rows matched on a bare `role="button"`, which passes vacuously
the day the card becomes a real button and fails for the wrong reason if
anything else in the subtree grows one. They carry the same stable marker the
visit card took.

`/api/cycle/calendar` reads the same wrist-temperature channel the crosstab
does and is deliberately not fenced, and it was the only one of the pair with
nothing saying why: no value from it reaches the response.

The encrypted export's new 403 documented the switch refusal and not the
narrow-scope one it answers first, unlike the two settings operations beside
it. Both sheets say at the top that their Save and Delete are gated at the
caller, so a third mount reads the constraint before it violates it. And one
docblock line ran past the wrap.
2026-09-10 12:23:59 +02:00
Marc-André Bombeck 3017e24bb1 Merge the needs-based onboarding's server state and the derivation that never hides data 2026-09-10 12:23:34 +02:00
Marc-André Bombeck 44b6ea01c6 fix(onboarding): tidy the small edges around the setup routes
Four of them. The confirm re-stamped its completion instant on every call, so
a replay quietly moved the moment the setup finished at while the derivation
beside it was carefully latched. The two list answers were stored exactly as
sent, duplicates included, so the column could hold something the reader would
never return — the reader de-duplicates on the way out and now so does the
write. The wide-event name for saving an answer used a different noun from its
two siblings, which makes a dashboard filter awkward for one feature. And the
contract said the restart body was required while the route accepts none.
2026-09-10 12:14:32 +02:00
Marc-André Bombeck 681e69a723 fix(modules): drop the last three fail-open gates and discover the class instead of naming it
Six surfaces were named and fixed; nine carried the expression. `/labs`,
`/medications` and `/mood` still read `inSharedRecord || user?.modules?.X`, and
every precondition holds for them too: all three are presentable to their own
scoped grants, all three module keys map to a real section so the masked map
answers correctly for a delegate, and none of the three routes behind them
carries a module gate of its own — so a typed URL rendered a working page for a
record whose owner had switched the module off.

The guard is a discovered sweep now rather than a six-entry list. It walks
every component under `src/app` and `src/components` through the shared walker,
keeps the ones that read the module map, and asserts the SHAPE: no boolean `||`
joins the delegate flag to a module read. The search window cannot cross a `;`,
a `{` or a `}`, so it sees one expression at a time and a delegate check
elsewhere in the same file is none of its business. A tenth surface fails on
arrival.

Writing it turned up why the first version could not have found the three: the
comment stripper every structural guard in this directory re-spells removes
block comments first, and cannot tell a real opener from a `/*` inside a line
comment. `mood/page-client.tsx` explains its gate with "Every
`/api/mood-entries/*` route also enforces the gate", and that `/*` opened a
fake block that swallowed the next 2.2 kB of real code — the gate three lines
below it included. A guard reading that file was searching a source with a hole
in it and reporting a clean sweep. The shared walker now carries a stripper that
walks each line left to right and lets whichever opener comes first win; the
published-coverage sweep beside it moved onto the same one.
2026-09-10 12:14:14 +02:00
Marc-André Bombeck f90307fd22 fix(auth): answer the setup state for the record the payload describes
The account payload resolved the setup state for the actor while its module
map, its cycle flag and every count the checklist reads beside it answer for
the active record. A guardian looking at a child's dashboard therefore got the
child's rows ordered by their own answers, and the child's reading count
tested against their own flow state. The two halves of that component have to
agree about whose record they describe, so the field is record-scoped now,
like the module map next to it.

Under a grant narrowed to one section the answers come back empty, for the
reason the module map is masked: they name the domains the record tracks, and
a grant that opens readings does not open that. The shape of the flow still
comes through.

The three write routes stay actor-only and say why: a delegable declaration is
a reviewed entry in the sharing surface guard, and a read that is wider than
its write is the honest shape while that entry is still to come.
2026-09-10 12:06:02 +02:00
Marc-André Bombeck b10a3ef22e fix(onboarding): leave a trail for the three settings columns the flow writes
The setup flow writes a record's module map, its glucose unit and its unit
preference. All three have a dedicated settings route that records an audit
row, and the flow recorded none, so "why did my modules change" had no answer
in the activity panel. Restarting the flow did write a row, which made the gap
look like an oversight rather than a decision, because it was one.

Same event names as the dedicated routes, since it is the same column and the
panel should group them. The module row names the keys that actually moved and
the domains kept for their content.
2026-09-10 11:57:11 +02:00
Marc-André Bombeck 07f1b6ce92 refactor(modules): one record-keyed write for a record's module state
The guardian's modules route and the setup flow's confirm both decide a
record's modules, and each had its own copy of what that means: write the
preference blob, write the delegated cycle column, invalidate the health
score. Two copies of a three-step write drift, and the second one was added by
this branch. They share one function now, keyed on the record, which is the
carrier the managed-profile work needs.

Authentication stays at the routes and stays different on purpose — the
guardian route acts on a managed record, the setup routes refuse outright
under a switch until a guardian's route into a record's setup lands with the
rest of that record's configuration. So does the merge: what the answers imply
and what somebody just ticked are different questions about what may be
overwritten. The actor's own modules route keeps its own write, because that
one is guarded on a concurrency token the record-keyed callers do not carry.
2026-09-10 11:54:19 +02:00
Marc-André Bombeck 2d2350252f fix(onboarding): never switch a module off on a record that already uses it
The derivation wrote an explicit off for every module the answers did not
name, which is right for a record being set up and wrong for one with years of
rows behind it. An established self-hoster has, for most modules, no stored
preference at all — they were default-on and nobody ever touched the toggle —
so the first confirm took away the navigation entry, the dashboard widget and
the settings entry for domains full of content. The rows survived; the
surfaces did not.

A derived off now asks first, and only for the modules it would actually
retract. Ownership is not re-answered: the measurement domains come from the
same map the MCP reads and the coach snapshot gates off, and each remaining
module names the one table its content lives in, exhaustively, so a module
added later fails to compile until somebody says where its content is. A
module whose domain holds rows keeps whatever the record already had, because
absence is what "on" looks like in the stored map.
2026-09-10 11:48:27 +02:00
Marc-André Bombeck cc6e6ca221 fix(onboarding): derive the module map only from a questionnaire that was finished
The only gate on the derivation was Q1 being set, so one answer was enough to
turn the whole questionnaire into a module map. Two ways that landed wrong and
they compound. Somebody answers who the record is for, closes the tab, and
later finishes the legacy four-step wizard, which posts to this same endpoint
and knows nothing about these questions: the five unanswered ones each read as
their conservative default, eleven surfaces are written off, and the
once-only marker makes it unrepeatable. Or any client patches one answer and
posts the confirm straight back.

The gate now asks whether every question has an answer or a deliberate pass.
A half-answered flow derives nothing, is not stamped complete, and keeps its
confirm step pending, so finishing the questions later still does the work.
The legacy arm is untouched: a caller with no setup row still answers the
fixed acknowledgement it always did.
2026-09-10 11:42:38 +02:00