Files
HealthLog/src/lib/sharing/account-access.ts
T

150 lines
6.3 KiB
TypeScript

/**
* v1.36.0 — what a caller may do with account sharing, resolved server-side.
*
* This module answers three questions for one caller, once per `/api/auth/me`:
* which records may I open, which one am I inside right now, and what may I do
* in it. Every answer is a VALUE, not an input to a client-side calculation.
*
* That is the whole design of this file and it is worth stating plainly,
* because the tempting alternative is one line shorter: publish the grant rows
* and let the client work out what they mean. Then two programs decide one
* person's access — the resolver in `src/lib/api-handler.ts`, and whichever
* client is being read today — and they answer differently the first time an
* expiry, a revocation or a second access level appears. The client renders
* `canSwitch` and `canWrite`; it never computes them, and there is nothing in
* the payload it could compute them from.
*
* `canWrite` is resolved here through {@link grantAllows} against the real
* row rather than stored or hardcoded, which is why the level becoming an
* invitation choice needed no edit to this file: an accepted WRITE grant
* started answering true the moment one could exist, and an accepted MANAGE
* grant did the same one release later. It answers "may add to this record"
* and nothing beyond that; what a MANAGE grant additionally admits is carried
* by `level`, which is a value the client renders and never a flag it reasons
* from.
*
* v1.37.0 adds `sections` on the same terms. It is the stored scope run
* through the one resolver (`resolveGrantSections`), not the column: a client
* never sees a blob it would have to interpret, and the fail-closed reading of
* an unparseable one reaches the payload as the empty list rather than as
* silence.
*
* What is deliberately NOT published: the other account's avatar. The avatar
* bytes are owner-scoped (`/api/user/avatar/{id}` refuses any id but the
* caller's own), so a URL here would resolve to a 403 and paint a broken
* image. Opening that route to a delegate is a classification decision about
* a route, not a decision the account payload gets to make on the side.
* Clients paint the initials fallback they already have.
*/
import { prisma } from "@/lib/db";
import type {
AccountAccess,
AccountAccessEntry,
AccountAccessLevel,
} from "@/lib/sharing/account-access-view";
import { resolveGrantSections } from "@/lib/sharing/grant-view";
import { grantAllows, isGrantActive } from "@/lib/sharing/grants";
import type { AccountGrantAccess } from "@/generated/prisma/client";
/**
* The stored level, as the contract names it.
*
* A total map rather than a chain of ternaries, and the difference is the
* failure it removes: the shipped expression was
* `access === "WRITE" ? "write" : "read"`, which answered "read" for anything
* it did not recognise. When a third level arrived that would have published
* a MANAGE grant as read-only — the payload understating access is the
* direction that reads as safe and is not, because the client renders what it
* is told and the server would have gone on admitting the writes. An unlisted
* enum member does not typecheck here, so the next level cannot ship silent.
*/
const LEVEL: Record<AccountGrantAccess, AccountAccessLevel> = {
READ: "read",
WRITE: "write",
MANAGE: "manage",
};
/**
* Resolve the sharing block for one caller.
*
* The block is always present, even for an account with no grants in either
* direction: an empty `accounts` array with `canSwitch: false` says "nobody has
* shared anything with you", which is a different statement from a missing
* field (which says "this server does not know about sharing"). Absence reads
* as absence.
*
* Takes the whole auth context rather than a pair of ids, so the carrier column
* is named here and in no route file — the boundary guard in
* `src/__tests__/acting-account-boundary-guard.test.ts` keeps that list short
* on purpose, and a field plucked out at every call site is how such a list
* grows one entry per surface.
*
* The session's stamp is deliberately NOT trusted to mean access: an entry
* appears in `active` only if the same pass that
* built `accounts` admitted it. So a stamp left behind by a grant that lapsed
* between two requests reads as "not switched" here, which is exactly what the
* request resolver will independently decide about the next delegated read.
* The client's job on seeing that mismatch is to clear the stamp, not to
* reason about it.
*/
export async function resolveAccountAccess(auth: {
user: { id: string };
session: { actingAsUserId?: string | null };
}): Promise<AccountAccess> {
const actorId = auth.user.id;
const stamped = auth.session.actingAsUserId ?? null;
const now = new Date();
// Narrow in SQL on the one condition the partial unique index makes cheap,
// and let the state machine decide the rest — the same split
// `findActiveGrant` uses, and for the same reason: a SQL predicate spelling
// out "accepted and not expired" beside a TypeScript one is two places
// deciding one question.
const rows = await prisma.accountGrant.findMany({
where: { granteeId: actorId, revokedAt: null },
orderBy: { createdAt: "desc" },
include: {
grantor: {
select: {
id: true,
username: true,
displayName: true,
managedProfileAt: true,
},
},
},
});
const accounts: AccountAccessEntry[] = rows
.filter((grant) => isGrantActive(grant, now))
.map((grant) => {
// `access` is the shipped two-level field, so MANAGE remains write for
// clients that have not learned the canonical level. `level` names the
// complete three-level contract.
const level = LEVEL[grant.access];
return {
accountId: grant.grantor.id,
username: grant.grantor.username,
displayName: grant.grantor.displayName,
access: level === "read" ? "read" : "write",
level,
recordKind:
grant.grantor.managedProfileAt === null ? "shared" : "managed",
sections: resolveGrantSections(grant.scopeJson),
canWrite: grantAllows(grant, "write", now),
};
});
const active =
stamped === null
? null
: (accounts.find((a) => a.accountId === stamped) ?? null);
return {
accounts,
active,
recordKind: active?.recordKind ?? "self",
canSwitch: accounts.length > 0,
};
}