Merge pull request #690 from MBombeck/work/sharing-client

Account sharing, the part people touch
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-08-02 17:13:30 +02:00
commit 210380f46d
60 files changed
+5648 -174

No files matched your search

+6 -1
View File
@@ -26,7 +26,12 @@ notes, and machine-readable specs that ship alongside the source:
`OIDC_ONLY` consequences, and the break-glass runbook. Also
[`self-hosting/geolite2.md`](./self-hosting/geolite2.md) — bringing your
own offline GeoLite2 databases via `GEOLITE2_DIR` instead of sending
login IPs to a third-party lookup.
login IPs to a third-party lookup. And
[`self-hosting/account-sharing.md`](./self-hosting/account-sharing.md) —
letting one account on the instance open another's health record: what
the person given access can and cannot reach, what revocation does and
does not undo, and which trust properties of the instance are unchanged
by it.
- [`migration/`](./migration/) — release-by-release migration notes.
Read the entry for the version you're upgrading from.
- [`audit/`](./audit/) — per-release audit summaries archived for
+253 -27
View File
@@ -12432,6 +12432,61 @@ paths:
"401": *a1
"422": *a3
"429": *a4
/api/auth/me:
get:
tags:
- Auth
summary: The signed-in account, and what sharing lets it do
description: "Keeps answering while the browser is acting on another record — the switcher, the banner naming whose
record is open, and the way back out all read it, so a refusal here would strand a switched session. It is
therefore an ACTOR surface: it always serves the caller's own rows, and a request that attaches
`X-HealthLog-Account` is refused with 403 `sharing.not_permitted` rather than quietly answered. A client that
wants a record's data sends the selector on the read that needs it, never on this call."
responses:
"200":
description: The account payload.
content:
application/json:
schema:
$ref: "#/components/schemas/AccountPayloadEnvelope"
"401": *a1
"403":
description: "A selector header was attached to an actor surface (`meta.errorCode: sharing.not_permitted`)."
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a3
"429": *a4
/api/account/activity:
get:
tags:
- Account sharing
summary: Who opened this record, and when
description: "Every audit row filed under this account that somebody else performed, newest first — one row per delegate
per day with a count, rather than one per request. `retentionDays` rides the response because audit history is
purged on a schedule the operator configures: a view that showed the rows without the window would imply a
completeness it does not have, and clients must state the number the server sends rather than assume the 365-day
default. Not delegable: a grant is not an introduction to the other people in the household."
responses:
"200":
description: The activity, and how far back it can see.
content:
application/json:
schema:
$ref: "#/components/schemas/RecordActivityEnvelope"
"401": *a1
"403": &a20
description: Refused. `meta.errorCode` is `sharing.not_permitted` when the request was made while acting on another
account (grant management is never delegable), or `sharing.access.denied` when the caller may not act as the
account they named. The latter is byte-identical for an account that does not exist and one that granted
nothing — the refusal is not an enumeration oracle.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a3
"429": *a4
/api/account/grants:
get:
tags:
@@ -12446,15 +12501,7 @@ paths:
schema:
$ref: "#/components/schemas/AccountGrantListEnvelope"
"401": *a1
"403": &a20
description: Refused. `meta.errorCode` is `sharing.not_permitted` when the request was made while acting on another
account (grant management is never delegable), or `sharing.access.denied` when the caller may not act as the
account they named. The latter is byte-identical for an account that does not exist and one that granted
nothing — the refusal is not an enumeration oracle.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"403": *a20
"422": *a3
"429": *a4
post:
@@ -36558,6 +36605,186 @@ components:
- data
- error
additionalProperties: false
AccountPayloadEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/AccountPayload"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
AccountPayload:
type: object
properties:
id:
type: string
username:
type: string
accountAccess:
$ref: "#/components/schemas/AccountAccess"
required:
- id
- username
- accountAccess
additionalProperties: {}
description: "The signed-in account: its identity, its preferences, and (since v1.36.0) what account sharing lets it do.
Additional properties are the preference fields this spec does not yet enumerate. Under an active switch this
payload still describes the CALLER — their preferences, their modules, their identity — because display
preferences belong to the person at the keyboard rather than to the record they are reading."
AccountAccess:
type: object
properties:
accounts:
type: array
items:
$ref: "#/components/schemas/AccountAccessEntry"
description: Every record this caller may open, newest grant first.
active:
anyOf:
- $ref: "#/components/schemas/AccountAccessEntry"
- type: "null"
description: "The record this session is inside right now, resolved to a full entry rather than an id to look up — so a
banner can name the person without joining two fields. Null when the caller is in their own record, and
ALWAYS null on the Bearer transport: a token carries its selector per request and this endpoint refuses
one."
canSwitch:
type: boolean
description: Whether there is anywhere to switch to. Bind it directly.
required:
- accounts
- active
- canSwitch
additionalProperties: false
description: "Account sharing, resolved. This block is the ONLY source of switchability and writability: the server
publishes what the caller may do and the client renders it. A client that computed either from grant data would
be a second program deciding one person's access to a health record, and the two answer differently the first
time an expiry or a revocation appears. Always present — an account nobody has shared with gets an empty list,
not a missing field."
AccountAccessEntry:
type: object
properties:
accountId:
type: string
description: The account whose record it is — the value POST /api/account/switch takes, and the value the
X-HealthLog-Account header carries.
username:
type: string
displayName:
anyOf:
- type: string
- type: "null"
access:
type: string
enum:
- read
- write
canWrite:
type: boolean
description: Whether this caller may CHANGE that record. Resolved server-side; `false` for every grant in v1. Render it;
never derive it.
required:
- accountId
- username
- displayName
- access
- canWrite
additionalProperties: false
description: "One account this caller may act on. No avatar is published: the avatar bytes are owner-scoped, so a URL
here would resolve to a refusal. Clients paint their initials fallback."
RecordActivityEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/RecordActivity"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
RecordActivity:
type: object
properties:
entries:
type: array
items:
$ref: "#/components/schemas/RecordActivityEntry"
retentionDays:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
description: How far back this feed can see, in days. Resolved from the instance's audit retention setting (365 by
default, operator-configurable), NOT a constant — an instance running a 90-day window sends 90. Show it
beside the rows.
required:
- entries
- retentionDays
additionalProperties: false
description: The owner's record of what somebody else did in their record, bounded by the instance's audit retention.
RecordActivityEntry:
type: object
properties:
id:
type: string
actor:
anyOf:
- $ref: "#/components/schemas/AccountGrantParty"
- type: "null"
description: Who did it. Null when that account has since been deleted — the audit column is deliberately not a foreign
key, so history is not rewritten when somebody leaves. Render it as a deleted account; a blank would read as
though the owner had done it themselves.
action:
type: string
accesses:
anyOf:
- type: integer
minimum: -9007199254740991
maximum: 9007199254740991
- type: "null"
description: How many times that delegate opened the record on that day, for a coalesced read row. Null for anything
else — a single act has no count, and a 1 there would invite adding up numbers that mean different things.
createdAt:
type: string
required:
- id
- actor
- action
- accesses
- createdAt
additionalProperties: false
AccountGrantParty:
type: object
properties:
id:
type: string
username:
type: string
displayName:
anyOf:
- type: string
- type: "null"
required:
- id
- username
- displayName
additionalProperties: false
description: "The account on the other end of a grant. E-mail addresses are deliberately not published: a grant is not a
way to collect the other party's contact details."
AccountGrantListEnvelope:
type: object
properties:
@@ -36647,24 +36874,6 @@ components:
accepted, when and by whom it ended — so ended grants stay in the list with `state: REVOKED` rather than
disappearing. `revokedBy` distinguishes the owner withdrawing access from the delegate handing it back.
`lastUsedAt` mirrors API-token semantics: the last time the grant was actually exercised."
AccountGrantParty:
type: object
properties:
id:
type: string
username:
type: string
displayName:
anyOf:
- type: string
- type: "null"
required:
- id
- username
- displayName
additionalProperties: false
description: "The account on the other end of a grant. E-mail addresses are deliberately not published: a grant is not a
way to collect the other party's contact details."
AccountGrantState:
type: string
enum:
@@ -37272,6 +37481,23 @@ components:
- showEvidenceByDefault
- defaultWindow
additionalProperties: false
parameters:
AccountSelector:
name: X-HealthLog-Account
in: header
required: false
schema:
type: string
maxLength: 64
description: "Act on another account's record for THIS request. Bearer transport only — the browser session carries its
switch on the session row, and sending this header alongside a cookie is refused rather than ignored. The value
is an `accountId` from `accountAccess.accounts` on GET /api/auth/me. It is a SELECTOR and not a permission: the
grant is re-checked on every request, so a revocation lands on the next one rather than at the next login, and a
selector naming an account that granted nothing is refused with 403 `sharing.access.denied` — byte-identical to
one naming an account that does not exist, so the refusal is not an enumeration oracle. NEVER attach it to the
sync engine, device or notification registration, or any auth/refresh call: those are about the person holding
the token, not the record being read, and the account payload refuses the header outright for exactly that
reason."
securitySchemes:
bearerAuth:
type: http
+146
View File
@@ -0,0 +1,146 @@
# Sharing a health record with another account
Two accounts on the same instance can be connected so that one of them
can open the other's health record. A parent and an adult child, two
partners, somebody keeping an eye on a relative's blood pressure — the
case this exists for is a household, not an organisation.
This page describes what that connection does, what it deliberately does
not do, and which of the instance's existing trust properties it leaves
exactly as they were.
## The shape of it
One account **shares**; the other is **given access**. In the code and in
the settings copy these are the owner and the person they invited.
- The owner opens **Settings → Shared access** and invites somebody by
the username or e-mail address that person signs in with. They need an
account on this instance already; there is no invite-by-e-mail flow.
- **The invitation confers nothing.** Until the other person accepts it,
it is an offer sitting in their own settings. Being handed read access
to somebody's health record is not something the product imposes
silently, and the acceptance — with its timestamp and the accepting IP —
is what the row records.
- Once accepted, the record shows up in that person's user menu under
"Open a record". Opening it reloads the app into the owner's record.
- A banner across the top of every page names whose record is open and
says it is read-only, with a button back to the person's own. It is
loud on purpose: somebody who forgets they are switched will log their
own reading into another person's record, and there is no undo for an
entry that was never theirs.
- Either side can end it at any time. The owner withdraws access; the
other person hands it back. Both are one click with no second factor
and no typed confirmation — reducing access must never be harder than
granting it was.
## What the other person can and cannot do
**Read the health record.** Measurements, medications, mood, labs, the
illness journal, preventive-care reminders, documents. In this release
that is the whole of it: every grant is read-only. The write level exists
in the data model so that adding it later is enforcement work rather than
a migration, and no invitation can create one today.
**Nothing about the account around the record.** This is the line the
whole feature is built on, and it is drawn on the server:
| Not reachable | Why it would matter |
| -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| Password, passkeys, second factor, recovery codes | Silent takeover, surviving any revocation |
| Sessions, trusted devices, sign-out-everywhere | Ending or enumerating the owner's logins |
| API tokens, the MCP connector | A durable credential that outlives the access |
| Connected services (Withings, Fitbit, WHOOP, Google Health, Polar, Oura, Strava, Nightscout) | Re-pointing a sync, or feeding somebody else's wearable into this record |
| Notification channels and push devices | The owner's health alerts redirected to another phone or chat |
| Exports, the health-record archive, clinician share links | A one-click copy of everything, or a new door that outlives the access |
| Insights and the Coach | AI processing of the owner's data, under the owner's own consent |
| Sharing itself | Inviting somebody else, widening the access, or passing it on |
| Locale, theme, dashboard layout | Presentation belongs to the person, not to the record |
The last two rows are worth a sentence each. Sharing is not
transferable: somebody given access cannot invite a third person, cannot
raise their own level, and cannot hand it on. And display preferences
stay with the person at the keyboard, so reading somebody else's record
does not change your own theme, language or dashboard.
The AI row is the one that looks arbitrary and is not. Server-managed AI
features send the record's data to a provider under a consent the record's
OWNER gave, for their own use. Somebody else triggering that would create
a consent-shaped act the owner never made, so those surfaces stay
owner-only.
These are not checks that a page forgot to render. Every route in the
product refuses by default while a browser is acting on another account;
a route becomes reachable only by declaring it, and the set of declared
routes is frozen by a test.
## Revocation, and what it cannot undo
Ending access takes effect on the other person's **next request**, not at
their next login. The grant is checked against the database on every
single request, so there is no cached verdict to wait out. If their
browser is sitting inside the record when the owner ends it, the same
database transaction puts that browser back into its own account — they
land at home rather than on a wall of refusals.
What revocation cannot do is take back what was already seen. A person
who had read access could have read, remembered, screenshotted or written
down anything in the record. The product can close the door; it cannot
reach through it. Nothing in the interface implies otherwise, and neither
does this page.
Ending access never deletes the record of it. The row stays with the
dates and with who ended it, because "who had access, from when to when,
and who ended it" is a question a deleted row cannot answer. Re-inviting
the same person creates a new row beside the old one.
## Seeing what happened
**Settings → Shared access** answers three questions for the owner, at
any time: who can open this record, since when, and when they last did.
Below that, a list of the days somebody else opened it and how many times
— one line per person per day, rather than a line per request.
That list is bounded by the instance's audit retention, and the page says
so with the actual number rather than a hardcoded one. The default is 365
days (`AUDIT_LOG_RETENTION_DAYS`); an operator who shortens it shortens
this view too, and the sentence under the heading changes to match. An
empty list means nobody opened the record within that window — not that
nobody ever did.
## What this does not change about the instance
Stated plainly, because a sharing feature is exactly the moment somebody
re-reads the trust model:
- **The operator could always read the database.** Anyone with server or
database access, and anyone with an admin account on this instance, can
reach health data directly. That was true before sharing existed and is
unchanged by it. On a household instance the operator is usually one of
the people using it; on an instance hosting other people, it is worth
saying out loud.
- **Encryption at rest is instance-keyed, not per-user.** The encrypted
columns are protected by the key in `ENCRYPTION_KEYS`, which belongs to
the instance. Sharing does not weaken that, and it never did protect one
account's data from the operator of the same instance.
- **AI processing stays under the record owner's consent**, because the AI
surfaces are not part of what sharing covers.
## For operators
Nothing to configure. Sharing is available on every instance, needs no
environment variable, and does nothing until one account invites another.
Two operational notes:
- **Invitations disclose whether a username exists.** An invitation to a
name nobody uses is refused with "no account with that name". That is
deliberate: the alternative is a silent pending row that will never be
accepted because the owner mistyped. The caller is already
authenticated and the endpoint allows ten invitations an hour per
account, which bounds how fast the surface could be walked.
- **Deleting an account** removes the grants it is party to. The audit
rows naming that account as the one who opened somebody's record stay,
and render as "a deleted account" — nulling them would turn "your
daughter opened your record" into "you opened your record", which is
worse than an unresolvable id.