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

+58
View File
@@ -0,0 +1,58 @@
/**
* v1.36.0 — the shape of the sharing block, and nothing else.
*
* Types only, with no imports at all, because both ends read them: the route
* that builds the block (`account-access.ts`, which talks to Postgres) and the
* client that renders it. Declaring them beside the server code would put a
* module that imports Prisma on the client's import graph — erased at compile
* today, one careless value export away from being bundled tomorrow. The
* boundary is cheaper to keep than to rediscover.
*
* What these types are FOR is stated once here and holds everywhere they are
* used: they carry resolved answers. `canWrite` and `canSwitch` are decisions
* the server has already made; a client that recomputed either from the rest
* of the payload would be the second program deciding one person's access.
*/
/** One account this caller may act on. */
export interface AccountAccessEntry {
/** The account whose record it is — the value `POST /api/account/switch` takes. */
accountId: string;
username: string;
displayName: string | null;
/** The grant's level. `"read"` throughout v1. */
access: "read" | "write";
/** May this caller change that record. Resolved server-side; `false` in v1. */
canWrite: boolean;
}
/** The `accountAccess` block on `GET /api/auth/me`. */
export interface AccountAccess {
/** Every account this caller may open, newest grant first. */
accounts: AccountAccessEntry[];
/**
* The record this browser is inside right now, resolved to a full entry —
* not an id the client has to look up. Null when it is in its own record,
* and always null on the Bearer transport.
*/
active: AccountAccessEntry | null;
/** Is there anywhere to switch to. */
canSwitch: boolean;
}
/**
* The name to put in front of a person, given what the payload published.
*
* `displayName` when the account set one, the username otherwise. This is a
* presentation fallback and not a derivation of anything the server decided —
* both values are published, and which of the two reads better is the client's
* business. Kept here so the banner, the switcher and the sharing panel cannot
* disagree about what to call somebody.
*/
export function accountLabel(entry: {
displayName: string | null;
username: string;
}): string {
const named = entry.displayName?.trim();
return named && named.length > 0 ? named : entry.username;
}
+97
View File
@@ -0,0 +1,97 @@
/**
* 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 `false` for every entry in v1, because `inviteGrant` writes
* READ and only READ. It is resolved here through {@link grantAllows} against
* the real row rather than hardcoded, so the day a WRITE grant exists the
* answer changes without this file being edited — and, more importantly, so
* that nobody has to remember that it should.
*
* 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,
} from "@/lib/sharing/account-access-view";
import { grantAllows, isGrantActive } from "@/lib/sharing/grants";
/**
* 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 } },
},
});
const accounts: AccountAccessEntry[] = rows
.filter((grant) => isGrantActive(grant, now))
.map((grant) => ({
accountId: grant.grantor.id,
username: grant.grantor.username,
displayName: grant.grantor.displayName,
access: grant.access === "WRITE" ? "write" : "read",
canWrite: grantAllows(grant, "write", now),
}));
const active =
stamped === null
? null
: (accounts.find((a) => a.accountId === stamped) ?? null);
return { accounts, active, canSwitch: accounts.length > 0 };
}