resolveBearerToken took an optional permission, and an unset one admitted
any valid token. 324 of the 330 route files pass no permission, so a token
minted with a narrow scope reached the full-backup export, the labs and
measurement surfaces, the coach, and the bulk deletes — the settings copy
tells users to hand exactly that token to third-party automations.
Invert the default. The scope argument becomes a required ScopeRequirement
union: a route that names no scope now accepts cookie sessions and
cookie-equivalent ["*"] tokens only, and refuses a narrow token with 403 and
an undeclared_scope audit row. A route that means to accept a narrow token
names it, as the FHIR routes already do.
No route file changes. The absence of an argument becomes a positive
statement rather than an omission, so a newly added route denies a newly
minted narrow scope by default — the safe outcome is the one you get by
doing nothing.
/mcp keeps its own posture through an explicit any-valid-token requirement:
it authenticates here and authorises downstream on audience and write scope.
That is the single deliberate fail-open call site in the tree, and making the
parameter required is what keeps it greppable.
An MCP-audience token loses its REST read leg as a result, narrowing it to
/mcp only. The isMcpAudienceToken guard stays as defence in depth.
Dashboard layout changes take effect immediately: the save now evicts the
locale-keyed home-screen snapshot it previously missed, so a re-ordered or
toggled tile set no longer reverts to the prior selection for the SWR window.
A share link built with a narrowed report-section selection now serves exactly
that selection. The stored selection is the grouped export shape; the shared
view read it through the flat parser, which dropped every grouped toggle and
fell back to the full defaults — an over-share against the owner's choice. The
view now folds the grouped shape down faithfully. Documents-only shares were
never affected.
Hardening: the shared-document download path now sanitises the filename
identically to the owner download path, and thumbnail generation re-checks the
pixel ceiling after decode so a malformed image that slips past the header
sniff still cannot exhaust the worker.
Introduce a read-only Model Context Protocol surface built on the official
@modelcontextprotocol/sdk so a user can query their own HealthLog data from an
MCP-capable assistant over local stdio with a pasted, scoped Bearer token — no
extra infrastructure.
The tool and resource layer in src/lib/mcp is transport-agnostic: five read
tools — list_metrics, get_metric_series, get_medication_compliance, get_labs,
get_correlations — each a thin wrapper over the existing server-authoritative
retrieval paths, returning structured values, units, reference bands, and
provenance with an explicit { present: false } for absence and no prose
verdicts. Two resources expose the user's profile (data-minimised) and
medication list. A stdio entrypoint (src/cli/mcp-stdio.ts, pnpm mcp:stdio)
resolves the token through the shared Bearer path and binds the session to
<user>:<token>; with no token it refuses to start.
Read-only and admin-unreachable by construction: only read tools are
registered, and requireAdmin stays cookie-only so the Bearer/MCP wire can never
elevate. Covered by unit tests for every tool and resource, the auth/scope
model, and the cookie-only admin boundary. The server ships in the existing
container; docker compose up is unchanged.