Commit Graph
15 Commits
Author SHA1 Message Date
Marc-André Bombeck 8617f809f0 feat(sharing): hand a managed profile over to the person it describes
A guardian with a fresh second factor mints a one-time handover link
(hlp_ token, HMAC hash stored only, 1/7/14 days, at most one open per
profile, withdrawable) with a proposed access level per guardian: end,
view only or manage, view only by default. The link is shown once with a
copy button and a QR code; nothing is mailed. The panel sits at the end
of each managed-profile row and hints when the person is under 16.

The person opens /claim/<token>, sees a minimal preview (name, expiry,
who keeps which access, no health data) and chooses a username, a
required email and a password under the registration policy. The claim
is one transaction under the managed-profile lock: the token is
consumed, the credentials are set, the marker is cleared, every pending
invitation the record offered is withdrawn, each guardian's access is
set to its proposal, the disclaimer, onboarding and AI consent are
reset, and every step is audited on the record and on each guardian's
own account. A failure anywhere leaves the profile managed and the link
unused. Each guardian is notified on their own channels.

The new owner then sees each guardian's access on the first screen after
signing in and decides finally; until then the proposal holds, and the
decision waits at the top of Shared access. A guardian whose access
moved since the claim is left alone. The same screen points the owner
at their own notification channels, since reminders no longer reach the
guardians.

Both anonymous routes refuse a single-sign-on-only instance, refuse a
live session before spending the per-address rate limit, check the link
before probing names, and answer one 404 for every unusable token. The
token travels in request bodies only; hlp_ joins the redaction and
secret-shape rules and /claim/ the path-secret registry. Usernames
starting with managed- are reserved. The session-surface and acting-
account guards list the new surfaces with their reasons.
2026-10-08 11:18:14 +02:00
Marc-André Bombeck e0692efba8 fix(sharing): widen the grant db slice for the expired-row sweep
The pre-create sweep in inviteGrant writes an audit row when it closes a
lapsed grant, so the transaction slice it accepts now covers auditLog
alongside accountGrant. Point the module and route test fakes at the
same shape: they stub updateMany to close nothing, so the audit branch
stays unreached in the unit fixtures.
2026-08-08 21:14:23 +02:00
Marc-André Bombeck de83954a7f fix(sharing): let an expired grant be re-granted instead of colliding
The live-pair uniqueness is a partial index (WHERE revoked_at IS NULL),
which cannot read the clock: an expired but never-revoked grant kept
occupying the slot, so re-inviting the same person after expiry always
failed as a duplicate though they had no access. Close the lapsed row at
the moment of re-invite — conditionally, the expiry state in the WHERE —
so revocation still never deletes and the fresh invitation mints its row.
The revoker is stamped GRANTOR because the paired revoked-by check
requires both columns set and the enum has no expiry member. An audit row
records the close. Proven against the real index: the guard was watched
fail with a duplicate before the fix landed.
2026-08-08 21:01:50 +02:00
Marc-André Bombeck 389f2a042b fix: complete managed Guardian invitation lifecycle 2026-08-06 03:19:34 +02:00
Marc-André Bombeck 32380b1935 fix: clear revoked Guardian sessions
Return a removed Guardian's browsers to their own record.
2026-08-06 03:08:51 +02:00
Marc-André Bombeck ffe3225639 fix: complete managed Guardian lifecycle
Use locked Guardian membership and transactional lifecycle audits.\n\nKeep acceptance and marker changes atomic.
2026-08-06 03:05:19 +02:00
Marc-André Bombeck 523ab3220b fix: serialize managed profile Guardians
Protect every Guardian-reducing lifecycle transition with a record lock.\n\nReturn a recoverable refusal when the final Guardian remains.
2026-08-06 02:50:30 +02:00
Marc-André Bombeck e5e3590ca3 feat: add managed profile Guardians
- Keep the ordinary invitation consent flow for a second Guardian\n- Clear managed Guardian expiry when the invitation is accepted
2026-08-06 02:32:59 +02:00
Marc-André Bombeck e4e773d543 feat: define scoped sharing levels 2026-08-05 23:58:18 +02:00
Marc-André Bombeck da3cabb55f Give a grant a section set and a third level
A grant has meant one thing since it shipped: the whole record, to read or to
add to. This lays the words for the two shapes the rest of the sharing work
needs — part of a record, and management of one — while changing what no
existing grant does.

`ShareDomain` is a closed set of eight sections in `src/lib/sharing/scope.ts`,
clustered from the delegable read surfaces rather than invented, plus the
distinguished `record` for the routes that read across them. It never converts
to a `ModuleKey` and never imports one: two masks, two questions, no shared
spelling, because the day they share a name is the day somebody makes one
imply the other, and a module the owner enabled is not a section the owner
shared.

`account_grants.scope_json` is NULL on every row that exists, and NULL is the
entire record — the answer those rows were consented as, so no backfill and no
re-consent. A non-NULL value is read through one fail-closed normaliser: a
blob it cannot parse opens nothing, including a section the blob happened to
name. That is the deliberate inverse of the module preference normaliser,
which fails open on purpose; both do the safe thing with a value nobody can
read, and the safe thing differs because one answers an authorization question
and the other a presentation question.

`MANAGE` joins the level enum, ordered above WRITE so a write grant can never
satisfy it, and carries no scope at all: management of part of a record
promises a boundary that cannot survive an edit, so the invite path refuses to
write one.

`requireRecordAuth` takes the section as a second required argument. The
requirement is the lever — a delegable route without a classification does not
typecheck, so the set of classified routes cannot fall behind the set of
delegable ones. All fifty-seven modules carry theirs. The structural guard
freezes each against the production vocabulary, fails when a module's own arms
disagree, and pins a non-zero count of resolved sections so a matcher that
stopped reading the argument fails instead of agreeing with everything.

`requireGuardianAuth` is exported and reaches nothing yet. It gates on the
managed-profile marker rather than on the grant, so an adult who granted
management of their own record cannot arrive at that record's settings by any
argument about what MANAGE means: widening MANAGE would widen the record
resolver and would not touch this one. Its frozen list lands empty, with the
declaration site as the positive control that keeps "nothing reaches it" a
measurement rather than a renamed symbol.

`users.managed_profile_at` says since when a record is one somebody else runs.
A timestamp and not a boolean, for a reason the schema has to keep possible:
clearing it is the handover, and the grants and the activity trail span it
untouched.
2026-08-04 17:11:40 +02:00
Marc-André Bombeck ca3a654e47 Say what a grant is now that it can carry two levels
The module header still described a permission to read, and the grantee field
still said "invited to read it". Both are half the sentence once the level is
a choice.
2026-08-03 10:14:06 +02:00
Marc-André Bombeck 533400ce60 Let an invitation carry the level it offers
A grant has always had an access column and `inviteGrant` has always written
READ into it. That was the right way to ship the first version: "this release
never grants write" was a property of the file rather than a promise in a
comment. Remove it deliberately now, so somebody sharing their record can offer
reading or reading plus adding.

The level is a required argument to `inviteGrant` and is defaulted in exactly
one place, the invite schema, where an omitted field becomes READ. An older
client's payload therefore stays valid and stays narrow, and the domain module
never guesses a level nobody named.

There is no upgrade path and that is the decision, not a gap. A grant is READ
or WRITE from the moment it is offered and stays that way until somebody ends
it. Widening a live one would change what the delegate agreed to without asking
them again, and their consent is half of what makes a write grant legitimate.
The way up is a new invitation, accepted again.

The audit row and the wide event carry the level: "who was given what, and by
whom" is the question that row exists to answer.
2026-08-03 10:12:08 +02:00
Marc-André Bombeck a463b1fa01 Stop recording the address a delegate accepts a grant from
A consent record has to answer who consented and when, and the grant row
already does. It does not have to answer from where, and an address kept for
as long as both accounts exist is personal data held past the point it
evidences anything. Nothing read the column, which makes it exactly the
write-only field scripts/audit-column-readers.ts exists to surface.

The acceptance stays evidenced: audit_logs records it with the caller's
address under that table's own retention. The column never reached the wire,
so the grant view and the OpenAPI contract are unchanged.

Written before the feature shipped, so migration 0295 drops an empty column.
2026-08-02 16:36:12 +02:00
Marc-André Bombeck 56447a9457 feat(sharing): invite, accept, switch, revoke, renounce
The lifecycle surface under /api/account. An owner offers read access to
somebody already on the instance, the delegate accepts, either side ends
it, and a browser session points itself at a record it has been granted.

Ending a grant clears the acting-account carrier on every session of the
delegate pointing at that record, in the same transaction that stamps the
row. Split apart, a revocation would leave a browser inside a record it
can no longer read: every request refused, the banner still naming the
owner, nothing on screen able to explain it.

Revocation has no step-up and no confirmation step. Reducing access must
never be harder than granting it was.

Grant management is non-delegable by construction rather than by a check:
every route here resolves through bare requireAuth(), which refuses while
the caller is acting on another account, so no code path through them
runs as somebody else. The switch endpoint is the one actor surface, and
it is the way back out. A delegate already inside a record renounces
after switching out, one request, rather than the fail-closed default
growing an exception that later gets widened.

The state machine is not re-implemented anywhere: routes call the grant
module's predicate and its transitions, and the session carrier has one
writer.
2026-08-02 13:04:25 +02:00
Marc-André Bombeck 33c879dd6b feat(sharing): add the account grant and its lifecycle module
An account grant is one account's standing permission to read another
account's record. The row is also the consent record for the act that
created it: there is no second receipt table, because two records of one
act drift and then nobody can say which is true.

That shapes the table. Revocation stamps revoked_at and revoked_by
instead of deleting, so the record can still answer who had access, from
when to when, and who ended it. The uniqueness constraint is therefore
partial — one live grant per (owner, delegate), with history accumulating
underneath — otherwise a re-invitation would either be refused or would
overwrite the first grant's acceptance. Both foreign keys cascade: a
grant naming a user who no longer exists is an authorization nobody can
resolve and nobody can revoke.

The state machine lives in src/lib/sharing/grants.ts and only there. The
acting-account resolver and the grant surfaces both read these rows, and
if each carried its own idea of "active" the drift would be silent in
exactly one direction: the resolver admitting a grant the panel shows as
ended. Pending confers nothing, expiry is checked against the clock of
the request that reads the row, and every transition is a conditional
update so two concurrent accepts cannot both win.

The access column carries READ or WRITE and only READ is ever written.
It exists from day one so that delegated writes become enforcement work
rather than migration work.

Three lifecycle paths, each proven against the real route:

- Delete All Data removes grants from either side. The wipe loop asks
  every model for "this account's rows", and a table relating two
  accounts has no user_id to answer with, so wipe-plan.ts now declares
  the owner columns and translates. Wiping only the grantor side would
  leave the account still holding access to other people's records.
- Account deletion removes them in both directions.
- An admin restore leaves them exactly as it found them. Grants are
  classified NOT_IN_BACKUP: rolling data back is a decision an admin
  makes, but rolling an authorization back is a decision nobody makes,
  and a grant revoked on Tuesday must not come back alive out of
  Monday's file. After a restore, sharing is off and both people
  re-consent.

The backup classification guard was reading a user relation by field
name, so a model naming its account relations grantor and grantee read
as instance-scoped and was excused from having a verdict at all. It now
reads the type. That surfaced InviteToken, classified here too.
2026-08-02 10:03:04 +02:00