mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
150 lines
6.3 KiB
TypeScript
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,
|
|
};
|
|
}
|