diff --git a/src/__tests__/acting-account-boundary-guard.test.ts b/src/__tests__/acting-account-boundary-guard.test.ts index 3d9690e76..ca79edcc2 100644 --- a/src/__tests__/acting-account-boundary-guard.test.ts +++ b/src/__tests__/acting-account-boundary-guard.test.ts @@ -83,6 +83,16 @@ describe("the acting-account carrier is read in one place", () => { // themselves call these two functions and never name the column, which is // what keeps this list from growing one entry per surface. "lib/sharing/acting-session.ts", + // v1.36.0 — the only thing that PUBLISHES it, and it publishes the stamp + // only after re-deciding it. The account payload has to say which record + // the browser is inside so the banner can name a person, but the value it + // prints is not the column: an entry reaches `active` solely by surviving + // the same live-grant pass that builds the switcher list, so a stamp left + // behind by a lapsed grant reads as "not switched" — the same answer the + // resolver will independently give the next delegated request. Takes the + // whole auth context for exactly this reason: had it taken the id, the + // route would name the column too, and the list would grow per surface. + "lib/sharing/account-access.ts", ].sort(); it("no other file reads or writes the carrier column", () => { diff --git a/src/__tests__/delegable-surface-guard.test.ts b/src/__tests__/delegable-surface-guard.test.ts index 526090ab1..ea33dbde6 100644 --- a/src/__tests__/delegable-surface-guard.test.ts +++ b/src/__tests__/delegable-surface-guard.test.ts @@ -252,13 +252,15 @@ const DELEGABLE_ROUTES: Record = {}; * * The intended members are the surfaces a switched session needs in order to * stay usable and to get back out: the account bootstrap payload, the switch - * endpoint, logout, the native refresh route, the locale read. One of them - * exists so far. The rest arrive as their own diffs here; naming them before + * endpoint, logout, the native refresh route, the locale read. Two of them + * exist so far. The rest arrive as their own diffs here; naming them before * they exist would freeze a guess. */ const ACTOR_ROUTES: Record = { "app/api/account/switch/route.ts": "The way back out. Every other route refuses under a switch, so if this one did too a browser could enter a record and never leave it. It reads and writes exactly one row — the caller's own session — renders nothing of the owner's, and grants nothing: the account it stamps is validated against a live grant first, and the stamp is re-checked on every request after.", + "app/api/auth/me/route.ts": + "The app shell reads it on every boot, including while a switch is on: the switcher, the banner naming whose record is open, and the route back out all bind this payload. Every field it returns is the caller's own — their preferences, their modules, their identity — and the one field about the switch says only which records they may open and which they are inside. It reads no row of the owner's, and its `accountAccess` block grants nothing: the resolver re-checks the grant on every delegated request regardless of what this payload said a moment ago.", }; /** @@ -266,7 +268,7 @@ const ACTOR_ROUTES: Record = { * a formality by accident: every addition has to be counted here as well as * listed above, which is one more place a careless admission has to pass. */ -const FROZEN_ENTRY_COUNT = 1; +const FROZEN_ENTRY_COUNT = 2; /** * The two surfaces that authenticate a Bearer token outside `requireAuth` — diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index 90643a7b7..ee0d395f5 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -1,5 +1,23 @@ +/** + * `GET /api/auth/me` — the account payload, and the one surface that keeps + * answering while the browser is acting on somebody else's record. + * + * v1.36.0 made it an ACTOR surface (`requireActorAuth`). It has to be: the + * switcher, the banner naming whose record is open, and the way back out all + * read this payload, and all three are about the person at the keyboard, not + * the record they are looking at. Under a bare `requireAuth()` the whole app + * shell would 403 the moment a switch was on. + * + * So every field below is still the CALLER's own — their preferences, their + * modules, their identity — and the one field about the switch, + * `accountAccess`, says so explicitly. Nothing here ever renders the owner's + * data; a delegate reading their own display preferences while inside + * somebody else's record is the correct behaviour and the reason the mode + * exists (design §4: locale, theme and layout belong to the person, not the + * record). + */ import { apiSuccess } from "@/lib/api-response"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireActorAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; import { setOnboardingPendingCookie } from "@/lib/auth/session"; import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment"; @@ -12,11 +30,13 @@ import { getOperatorModuleAvailability, } from "@/lib/modules/gate"; import { parseTourProgress } from "@/lib/onboarding/tour-progress"; +import { resolveAccountAccess } from "@/lib/sharing/account-access"; export const dynamic = "force-dynamic"; export const GET = apiHandler(async () => { - const { user } = await requireAuth(); + const auth = await requireActorAuth(); + const { user } = auth; annotate({ action: { name: "auth.me" } }); @@ -27,33 +47,38 @@ export const GET = apiHandler(async () => { // cycle-tracking gate; no row is forced, a NULL toggle derives from // gender) is a Postgres round-trip. Running them sequentially added // the cookie hop to every /me — and /me sits on every app boot path. - const [, , cycleProfile, modules, moduleAvailability] = await Promise.all([ - setOnboardingPendingCookie(user.onboardingCompletedAt == null), - // v1.23 — keep the admin-enforced-MFA hint cookie honest on every app - // boot, mirroring the onboarding-cookie resync. A locally edited cookie is - // corrected here; an account that enrols (or has the policy lifted) loses - // the redirect on the next /me read. - syncMfaEnrollCookie(user.id, { - totpConfirmedAt: user.totpConfirmedAt, - mfaEnforced: user.mfaEnforced, - }), - prisma.cycleProfile.findUnique({ - where: { userId: user.id }, - select: { cycleTrackingEnabled: true }, - }), - // v1.18.0 — resolved module enable/disable map for every toggleable - // module. cycle/coach reflect their real delegated state (the cycle - // gate / disableCoach + operator assistant flag); the rest read the - // disabled-allowlist `modulePreferencesJson`. Default-on. Clients - // hide a whole module surface end-to-end when its key is `false`. - resolveModuleMap(user.id), - // v1.18.0 — operator-layer availability map (server-wide kill-switch). - // The resolved `modules` map above already AND-s this in, so it cannot - // distinguish operator-off from user-off. The Modules hub needs that - // distinction to render an operator-disabled module as a read-only - // "disabled server-wide" row instead of a live toggle that no-ops. - getOperatorModuleAvailability(), - ]); + const [, , cycleProfile, modules, moduleAvailability, accountAccess] = + await Promise.all([ + setOnboardingPendingCookie(user.onboardingCompletedAt == null), + // v1.23 — keep the admin-enforced-MFA hint cookie honest on every app + // boot, mirroring the onboarding-cookie resync. A locally edited cookie is + // corrected here; an account that enrols (or has the policy lifted) loses + // the redirect on the next /me read. + syncMfaEnrollCookie(user.id, { + totpConfirmedAt: user.totpConfirmedAt, + mfaEnforced: user.mfaEnforced, + }), + prisma.cycleProfile.findUnique({ + where: { userId: user.id }, + select: { cycleTrackingEnabled: true }, + }), + // v1.18.0 — resolved module enable/disable map for every toggleable + // module. cycle/coach reflect their real delegated state (the cycle + // gate / disableCoach + operator assistant flag); the rest read the + // disabled-allowlist `modulePreferencesJson`. Default-on. Clients + // hide a whole module surface end-to-end when its key is `false`. + resolveModuleMap(user.id), + // v1.18.0 — operator-layer availability map (server-wide kill-switch). + // The resolved `modules` map above already AND-s this in, so it cannot + // distinguish operator-off from user-off. The Modules hub needs that + // distinction to render an operator-disabled module as a read-only + // "disabled server-wide" row instead of a live toggle that no-ops. + getOperatorModuleAvailability(), + // v1.36.0 — which records this caller may open, which one they are + // inside, and what they may do there. Every value resolved server-side; + // see the module docblock for why none of it is left to the client. + resolveAccountAccess(auth), + ]); const cycleTrackingEnabled = isCycleEnabled(user.gender, cycleProfile); // v1.7.0 — patient-identity fields for the health-record export. The @@ -142,5 +167,11 @@ export const GET = apiHandler(async () => { // this to show a "disabled server-wide" read-only row; everywhere else // the already-AND-ed `modules` map is the single gate to read. moduleAvailability, + // v1.36.0 — account sharing, resolved. `accounts` is the switcher's menu, + // `active` is the record this session is inside (null when it is in its + // own), `canSwitch` and per-entry `canWrite` are the booleans the UI binds + // directly. Always present — an account with no grants gets an empty list, + // not a missing field. + accountAccess, }); }); diff --git a/src/lib/sharing/account-access-view.ts b/src/lib/sharing/account-access-view.ts new file mode 100644 index 000000000..264e3693c --- /dev/null +++ b/src/lib/sharing/account-access-view.ts @@ -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; +} diff --git a/src/lib/sharing/account-access.ts b/src/lib/sharing/account-access.ts new file mode 100644 index 000000000..1a0fe3f54 --- /dev/null +++ b/src/lib/sharing/account-access.ts @@ -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 { + 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 }; +} diff --git a/tests/integration/sharing-account-access.test.ts b/tests/integration/sharing-account-access.test.ts new file mode 100644 index 000000000..23755f461 --- /dev/null +++ b/tests/integration/sharing-account-access.test.ts @@ -0,0 +1,332 @@ +/** + * The `accountAccess` block on `GET /api/auth/me`, over the real route, + * against real Postgres. + * + * The block is the only thing either client is allowed to know about its own + * sharing permissions, so what is tested here is not "the field is present" + * but "the field says the same thing the request resolver would". Every case + * below is a state the grant table can actually be in — pending, active, + * expired, revoked, self — and the question each time is whether the payload + * would let a client paint a switcher entry the server would then refuse. + * + * Nothing is mocked below the route. A unit test with a stubbed Prisma could + * prove the route calls the resolver; it could not prove that a grant which + * lapsed an hour ago drops out of the list, which is the property the whole + * block exists to carry. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: () => {}, + delete: () => {}, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +import { ACCOUNT_SELECTOR_HEADER } from "@/lib/api-handler"; +import { hashToken } from "@/lib/auth/hmac"; + +let counter = 0; + +async function makeUser(label: string) { + const suffix = `${label}-${counter++}`; + return getPrismaClient().user.create({ + data: { + username: `access-${suffix}`, + email: `access-${suffix}@example.test`, + displayName: `Access ${suffix}`, + role: "USER", + onboardingCompletedAt: new Date(), + }, + }); +} + +async function signIn(userId: string) { + const session = await getPrismaClient().session.create({ + data: { userId, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + return session; +} + +interface AccountAccessEntry { + accountId: string; + username: string; + displayName: string | null; + access: "read" | "write"; + canWrite: boolean; +} + +interface AccountAccessBlock { + accounts: AccountAccessEntry[]; + active: AccountAccessEntry | null; + canSwitch: boolean; +} + +async function readMe(): Promise<{ + status: number; + accountAccess: AccountAccessBlock; + id: string; +}> { + const { GET } = await import("@/app/api/auth/me/route"); + const res = await GET(); + const body = (await res.json()) as { + data: { id: string; accountAccess: AccountAccessBlock }; + }; + return { + status: res.status, + accountAccess: body.data?.accountAccess, + id: body.data?.id, + }; +} + +/** An accepted, never-ending grant from `grantorId` to `granteeId`. */ +async function grantAccess( + grantorId: string, + granteeId: string, + extra: { expiresAt?: Date | null; acceptedAt?: Date | null } = {}, +) { + return getPrismaClient().accountGrant.create({ + data: { + grantorId, + granteeId, + access: "READ", + invitedAt: new Date(), + acceptedAt: extra.acceptedAt === undefined ? new Date() : extra.acceptedAt, + expiresAt: extra.expiresAt ?? null, + }, + }); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +describe("accountAccess — what the payload publishes", () => { + it("is present and empty for an account nobody has shared with", async () => { + const alone = await makeUser("alone"); + await signIn(alone.id); + + const { status, accountAccess } = await readMe(); + + expect(status).toBe(200); + // Present, not missing. A client that got no field could not tell "this + // server does not do sharing" from "nobody shared anything with me", and + // would have to guess which. + expect(accountAccess).toEqual({ + accounts: [], + active: null, + canSwitch: false, + }); + }); + + it("lists an accepted grant with the level resolved, and canWrite false", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await grantAccess(owner.id, delegate.id); + await signIn(delegate.id); + + const { accountAccess } = await readMe(); + + expect(accountAccess.accounts).toEqual([ + { + accountId: owner.id, + username: owner.username, + displayName: owner.displayName, + access: "read", + canWrite: false, + }, + ]); + expect(accountAccess.canSwitch).toBe(true); + expect(accountAccess.active).toBeNull(); + }); + + it("omits a pending invitation — being offered access is not having it", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await grantAccess(owner.id, delegate.id, { acceptedAt: null }); + await signIn(delegate.id); + + const { accountAccess } = await readMe(); + + expect(accountAccess.accounts).toEqual([]); + expect(accountAccess.canSwitch).toBe(false); + }); + + it("omits a grant that has lapsed, against the request clock", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await grantAccess(owner.id, delegate.id, { + expiresAt: new Date(Date.now() - 60_000), + }); + await signIn(delegate.id); + + const { accountAccess } = await readMe(); + + expect(accountAccess.accounts).toEqual([]); + }); + + it("omits a revoked grant", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + const grant = await grantAccess(owner.id, delegate.id); + await getPrismaClient().accountGrant.update({ + where: { id: grant.id }, + data: { revokedAt: new Date(), revokedBy: "GRANTOR" }, + }); + await signIn(delegate.id); + + const { accountAccess } = await readMe(); + + expect(accountAccess.accounts).toEqual([]); + }); + + it("never lists a grant this account GAVE — the block is about what it may open", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await grantAccess(owner.id, delegate.id); + // The owner signs in. They gave the access; they did not receive any. + await signIn(owner.id); + + const { accountAccess } = await readMe(); + + expect(accountAccess.accounts).toEqual([]); + expect(accountAccess.canSwitch).toBe(false); + }); +}); + +describe("accountAccess — the active record", () => { + it("resolves the stamped account to a full entry, not just an id", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await grantAccess(owner.id, delegate.id); + const session = await signIn(delegate.id); + await getPrismaClient().session.update({ + where: { id: session.id }, + data: { actingAsUserId: owner.id }, + }); + + const { accountAccess, id } = await readMe(); + + // Still the delegate's own payload — the switch changes which record the + // delegable routes read, never whose preferences /me returns. + expect(id).toBe(delegate.id); + expect(accountAccess.active).toEqual({ + accountId: owner.id, + username: owner.username, + displayName: owner.displayName, + access: "read", + canWrite: false, + }); + // The active entry is one of the listed ones, by construction. A banner + // that had to join the two could render unnamed when they disagreed. + expect(accountAccess.accounts).toContainEqual(accountAccess.active); + }); + + it("reads as not-switched when the stamp outlives the grant", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + const grant = await grantAccess(owner.id, delegate.id); + const session = await signIn(delegate.id); + await getPrismaClient().session.update({ + where: { id: session.id }, + data: { actingAsUserId: owner.id }, + }); + // The grant lapses while the browser sits inside the record. Expiry has + // no session cleanup — only revocation does — so the stamp survives it. + await getPrismaClient().accountGrant.update({ + where: { id: grant.id }, + data: { expiresAt: new Date(Date.now() - 1000) }, + }); + + const { accountAccess } = await readMe(); + + // The payload agrees with what the resolver will decide about the next + // delegated read: there is no live grant, so there is no active record. + // The client's job is to clear the stamp, which is what the + // `sharing.access.denied` reset does. + expect(accountAccess.active).toBeNull(); + expect(accountAccess.accounts).toEqual([]); + }); +}); + +describe("accountAccess — the actor surface refuses a selector header", () => { + it("refuses a Bearer caller that attaches an account selector", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await grantAccess(owner.id, delegate.id); + + const raw = `hlk_${delegate.id}-token`.padEnd(20, "0"); + await getPrismaClient().apiToken.create({ + data: { + userId: delegate.id, + name: "account-access-test", + tokenHash: hashToken(raw), + permissions: ["*"], + }, + }); + + cookieJar.clear(); + headerJar.set("authorization", `Bearer ${raw}`); + headerJar.set(ACCOUNT_SELECTOR_HEADER, owner.id); + + const { GET } = await import("@/app/api/auth/me/route"); + const res = await GET(); + const body = (await res.json()) as { meta?: { errorCode?: string } }; + + // The account payload is DEFINED as the caller's own. A client that + // attached a selector to it believes it is asking about the owner and is + // not; answering correctly to the wrong question is how a support ticket + // about "data that looks wrong" starts. + expect(res.status).toBe(403); + expect(body.meta?.errorCode).toBe("sharing.not_permitted"); + }); + + it("serves a Bearer caller with no selector, and reports no active record", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + await grantAccess(owner.id, delegate.id); + + const raw = `hlk_${delegate.id}-plain`.padEnd(20, "0"); + await getPrismaClient().apiToken.create({ + data: { + userId: delegate.id, + name: "account-access-test", + tokenHash: hashToken(raw), + permissions: ["*"], + }, + }); + + cookieJar.clear(); + headerJar.set("authorization", `Bearer ${raw}`); + + const { status, accountAccess } = await readMe(); + + expect(status).toBe(200); + // The token transport carries its selector per request, so this payload + // is never inside a record. `accounts` still answers "which records may + // I open", which is what the native switcher renders. + expect(accountAccess.active).toBeNull(); + expect(accountAccess.accounts).toHaveLength(1); + expect(accountAccess.canSwitch).toBe(true); + }); +});