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.
This commit is contained in:
Marc-André Bombeck committed 2026-08-02 13:04:25 +02:00
1 parent 843c74c98c
commit 56447a9457
14 files changed
+2519 -1

No files matched your search

+549
View File
@@ -12432,6 +12432,259 @@ paths:
"401": *a1
"422": *a3
"429": *a4
/api/account/grants:
get:
tags:
- Account sharing
summary: List grants in both directions
description: Every grant this account has given or received, ended ones included. Refused while acting on another account.
responses:
"200":
description: Grants given and received.
content:
application/json:
schema:
$ref: "#/components/schemas/AccountGrantListEnvelope"
"401": *a1
"403": &a20
description: Refused. `meta.errorCode` is `sharing.not_permitted` when the request was made while acting on another
account (grant management is never delegable), or `sharing.access.denied` when the caller may not act as the
account they named. The latter is byte-identical for an account that does not exist and one that granted
nothing — the refusal is not an enumeration oracle.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a3
"429": *a4
post:
tags:
- Account sharing
summary: Invite an account to read this record
description: Offers read access to somebody already registered on this instance; the grant confers nothing until they
accept it. An identifier that names no account answers 404 — a deliberate disclosure to an authenticated caller
on a household instance, rate-limited to 10 invitations an hour, and the alternative (a silent pending row for a
mistyped username) is worse. Refused while acting on another account, so a delegate can neither invite nor
re-delegate.
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
identifier:
type: string
minLength: 1
maxLength: 255
expiresAt:
anyOf:
- type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- type: "null"
required:
- identifier
responses:
"201":
description: The pending grant.
content:
application/json:
schema:
$ref: "#/components/schemas/AccountGrantEnvelope"
"401": *a1
"403": *a20
"404":
description: No account on this instance carries that identifier.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"409":
description: "That account already holds a live grant on this record (`meta.errorCode: sharing.invite.duplicate`).
Re-inviting somebody whose access was revoked succeeds and mints a new row; the old one stays as history."
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422":
description: "Validation failed, or the invitation named the caller's own account (`meta.errorCode:
sharing.invite.self`)."
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"429":
description: Invitation rate limit exceeded.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
/api/account/grants/{id}/accept:
post:
tags:
- Account sharing
summary: Accept an invitation
description: "The delegate's own act, and the consent record: it stamps the acceptance time and the accepting IP on the
grant row. One-shot and delegate-only — the invited account comes from the session, so an invitation addressed
to somebody else answers 404 rather than confirming it exists."
parameters:
- in: path
name: id
schema:
type: string
required: true
responses:
"200":
description: The now-active grant.
content:
application/json:
schema:
$ref: "#/components/schemas/AcceptedAccountGrantEnvelope"
"401": *a1
"403": *a20
"404":
description: No such invitation for this account.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"409":
description: Already accepted.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"410":
description: The invitation lapsed or was withdrawn.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a3
"429": *a4
/api/account/grants/{id}:
delete:
tags:
- Account sharing
summary: Withdraw access (owner)
description: "Ends a grant on the caller's own record. No step-up and no confirmation step — reducing access must never
be harder than granting it. Enforcement is the delegate's next request, not their next login, and the same
transaction puts every browser session of theirs that was inside the record back into their own account. Nothing
is deleted: the row survives with `revokedAt` and `revokedBy: GRANTOR`."
parameters:
- in: path
name: id
schema:
type: string
required: true
responses:
"200":
description: The ended grant, and what the session cleanup did.
content:
application/json:
schema:
$ref: "#/components/schemas/RevokedAccountGrantEnvelope"
"401": *a1
"403": *a20
"404":
description: No such grant on this account's record.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"409":
description: That access had already ended.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a3
"429": *a4
/api/account/grants/{id}/renounce:
post:
tags:
- Account sharing
summary: Hand access back (delegate)
description: "The same transition as the owner's revoke, attributed the other way (`revokedBy: GRANTEE`) so the record
can tell a withdrawal from a handover. Same session cleanup. Refused while acting on the record being renounced
— switch back first (one request), which keeps grant management non-delegable without exceptions."
parameters:
- in: path
name: id
schema:
type: string
required: true
responses:
"200":
description: The ended grant, and what the session cleanup did.
content:
application/json:
schema:
$ref: "#/components/schemas/RenouncedAccountGrantEnvelope"
"401": *a1
"403": *a20
"404":
description: No such grant held by this account.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"409":
description: That access had already ended.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a3
"429": *a4
/api/account/switch:
post:
tags:
- Account sharing
summary: Act on a granted record, or return to your own
description: "Cookie transport only; a Bearer caller gets 400 (`meta.errorCode: sharing.switch.wrong_transport`) and
should send the `X-HealthLog-Account` header on the requests it wants scoped instead. The grant is validated
here so the client gets an honest refusal immediately, but the stamp authorises nothing on its own — every
following request re-checks the grant. `accountId: null` clears the switch and always works, including from
inside a switched session: this endpoint is the way out."
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
accountId:
anyOf:
- type: string
minLength: 1
maxLength: 64
- type: "null"
required:
- accountId
responses:
"200":
description: The account this session is now acting on, or null.
content:
application/json:
schema:
$ref: "#/components/schemas/AccountSwitchEnvelope"
"400":
description: Sent over the Bearer transport, which carries its selector per request instead.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"401": *a1
"403": *a20
"422":
description: Validation failed.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"429": *a4
components:
schemas:
EncryptedExportRequest:
@@ -36305,6 +36558,302 @@ components:
- data
- error
additionalProperties: false
AccountGrantListEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/AccountGrantList"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
AccountGrantList:
type: object
properties:
given:
type: array
items:
$ref: "#/components/schemas/AccountGrant"
received:
type: array
items:
$ref: "#/components/schemas/AccountGrant"
required:
- given
- received
additionalProperties: false
description: "Both directions at once: `given` are the grants this account has offered on its own record, `received` are
the ones offered to it. Capped at 100 rows per direction, newest first."
AccountGrant:
type: object
properties:
id:
type: string
account:
$ref: "#/components/schemas/AccountGrantParty"
access:
type: string
enum:
- READ
- WRITE
state:
$ref: "#/components/schemas/AccountGrantState"
invitedAt:
type: string
acceptedAt:
anyOf:
- type: string
- type: "null"
expiresAt:
anyOf:
- type: string
- type: "null"
lastUsedAt:
anyOf:
- type: string
- type: "null"
revokedAt:
anyOf:
- type: string
- type: "null"
revokedBy:
anyOf:
- type: string
enum:
- GRANTOR
- GRANTEE
- type: "null"
required:
- id
- account
- access
- state
- invitedAt
- acceptedAt
- expiresAt
- lastUsedAt
- revokedAt
- revokedBy
additionalProperties: false
description: "One grant, from either side. The row is also the consent record — who offered the access, when it was
accepted, when and by whom it ended — so ended grants stay in the list with `state: REVOKED` rather than
disappearing. `revokedBy` distinguishes the owner withdrawing access from the delegate handing it back.
`lastUsedAt` mirrors API-token semantics: the last time the grant was actually exercised."
AccountGrantParty:
type: object
properties:
id:
type: string
username:
type: string
displayName:
anyOf:
- type: string
- type: "null"
required:
- id
- username
- displayName
additionalProperties: false
description: "The account on the other end of a grant. E-mail addresses are deliberately not published: a grant is not a
way to collect the other party's contact details."
AccountGrantState:
type: string
enum:
- PENDING
- ACTIVE
- EXPIRED
- REVOKED
description: "What the grant is right now, resolved server-side against the request clock. Only ACTIVE confers anything.
Ordering is stated rather than implied: a revoked grant reads REVOKED even if it also sat past its expiry, and
an unaccepted invitation past its expiry reads EXPIRED rather than PENDING because it can no longer be accepted.
Clients render this value and never re-derive it from the timestamps."
AccountGrantEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/AccountGrant"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
AcceptedAccountGrantEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/AccountGrant"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
RevokedAccountGrantEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/EndedAccountGrant"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
EndedAccountGrant:
type: object
properties:
id:
type: string
account:
$ref: "#/components/schemas/AccountGrantParty"
access:
type: string
enum:
- READ
- WRITE
state:
$ref: "#/components/schemas/AccountGrantState"
invitedAt:
type: string
acceptedAt:
anyOf:
- type: string
- type: "null"
expiresAt:
anyOf:
- type: string
- type: "null"
lastUsedAt:
anyOf:
- type: string
- type: "null"
revokedAt:
anyOf:
- type: string
- type: "null"
revokedBy:
anyOf:
- type: string
enum:
- GRANTOR
- GRANTEE
- type: "null"
sessionsCleared:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
description: How many of the delegate's browser sessions were sitting inside the record and were put back into their own
account, in the same transaction that ended the grant.
required:
- id
- account
- access
- state
- invitedAt
- acceptedAt
- expiresAt
- lastUsedAt
- revokedAt
- revokedBy
- sessionsCleared
additionalProperties: false
description: "A grant that has just been ended, plus what the cleanup did. Nothing is deleted: the row survives as the
consent record and DELETE names the act, not the storage."
RenouncedAccountGrantEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/EndedAccountGrant"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
AccountSwitchEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/AccountSwitchState"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
AccountSwitchState:
type: object
properties:
actingAs:
anyOf:
- type: object
properties:
accountId:
type: string
username:
type: string
displayName:
anyOf:
- type: string
- type: "null"
access:
type: string
enum:
- READ
- WRITE
required:
- accountId
- username
- displayName
- access
additionalProperties: false
- type: "null"
required:
- actingAs
additionalProperties: false
description: The account this session is now acting on, or null when it is back in its own. The stamp is a selector and
not a permission — the grant is re-checked on every subsequent request, so a revocation lands on the next one
rather than at the next login.
MedicationScheduleOutput:
type: object
properties:
@@ -78,6 +78,11 @@ describe("the acting-account carrier is read in one place", () => {
"lib/auth/session.ts",
// The resolver. The only thing that acts on the value.
"lib/api-handler.ts",
// v1.36.0 — the only thing that WRITES it: the switch endpoint sets it
// through here, ending a grant clears it through here. The routes
// 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",
].sort();
it("no other file reads or writes the carrier column", () => {
@@ -91,9 +96,18 @@ describe("the acting-account carrier is read in one place", () => {
it("the selector header is named in one place", () => {
// A route that read the header itself would be deciding an authorisation
// question the resolver exists to answer once.
//
// The OpenAPI table is the one other file allowed to say the name, and it
// is not a reader: it publishes the header to the clients that have to
// send it. Listed explicitly rather than exempted by pattern, so a route
// that starts parsing the header cannot hide behind a rule about which
// directories are documentation.
const namers = filesMatching(/x-healthlog-account/i);
expect(namers.length).toBeGreaterThan(0);
expect(namers).toEqual(["lib/api-handler.ts"]);
expect(namers).toEqual([
"lib/api-handler.ts",
"lib/openapi/routes/account-sharing.ts",
]);
});
});
@@ -133,3 +147,106 @@ describe("the cookie-only helpers cannot see the acting account", () => {
expect(callers).toBe(2);
});
});
describe("grant management is not delegable by construction", () => {
/**
* A delegate must never grant, widen, or transfer access. The way that is
* true here is not a re-delegation check inside the handlers — a check is a
* line somebody can move — but the mode the handlers resolve in: bare
* `requireAuth()` refuses outright while a switch is active, so there is no
* code path through these routes that runs as somebody else at all.
*
* The switch endpoint is the deliberate exception and lives elsewhere: it is
* an actor surface because it is the way back out of a switch, and it grants
* nothing.
*/
const GRANT_ROUTES = [
"app/api/account/grants/route.ts",
"app/api/account/grants/[id]/route.ts",
"app/api/account/grants/[id]/accept/route.ts",
"app/api/account/grants/[id]/renounce/route.ts",
];
it("every grant route exists and resolves through bare requireAuth", () => {
// Non-zero proof: a renamed or moved route must fail here rather than
// leave the loop below with nothing to check.
const found = sourceFiles().filter((p) => GRANT_ROUTES.includes(p));
expect(found.sort()).toEqual([...GRANT_ROUTES].sort());
for (const rel of GRANT_ROUTES) {
const src = read(rel);
expect(src, `${rel} must resolve auth`).toMatch(/await requireAuth\(\)/);
expect(src, `${rel} must not honour a switch`).not.toContain(
"requireRecordAuth",
);
expect(src, `${rel} must not declare an actor surface`).not.toContain(
"requireActorAuth",
);
}
});
it("no grant route takes a party to the grant from the request body", () => {
// Both ends of a grant come from the session and from the row. A body
// field naming either would be the caller deciding an authorisation
// question, which is the one thing no route here is allowed to do.
for (const rel of GRANT_ROUTES) {
const src = read(rel);
expect(src).not.toMatch(/parsed\.data\.(grantorId|granteeId|userId)/);
}
});
});
describe("ending a grant and clearing the switch are one act", () => {
/**
* Two statements of the same fact. Split apart, a revocation that stamped
* the row but not the sessions leaves a delegate's 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.
*
* The behavioural half of this is
* `tests/integration/sharing-lifecycle.test.ts`, which asserts the session
* row after a revoke. This half holds the atomicity, which no test can
* observe from outside.
*/
function privateFunctionBody(src: string, name: string): string {
const start = src.indexOf(`async function ${name}(`);
if (start === -1) return "";
const open = src.indexOf("{", src.indexOf(")", start));
let depth = 0;
for (let i = open; i < src.length; i++) {
if (src[i] === "{") depth++;
else if (src[i] === "}") {
depth -= 1;
if (depth === 0) return src.slice(open, i + 1);
}
}
return "";
}
it("runs the transition and the cleanup in one transaction", () => {
const body = privateFunctionBody(
read("lib/sharing/grants.ts"),
"endAndClear",
);
// Non-zero proof: a renamed helper fails here rather than asserting
// against an empty string, which would pass every `not.toContain` below
// and most of what a reader assumes this test covers.
expect(body.length).toBeGreaterThan(0);
expect(body).toContain("$transaction");
expect(body).toContain("clearActingSessions");
});
it("routes an ending through the cleanup, never through the bare transition", () => {
for (const rel of [
"app/api/account/grants/[id]/route.ts",
"app/api/account/grants/[id]/renounce/route.ts",
]) {
const src = read(rel);
expect(src).toMatch(/(revoke|renounce)GrantAndClearSwitch/);
// The bare transitions leave sessions behind on purpose — they exist so
// they can run inside somebody else's transaction. A route reaching for
// one has skipped the cleanup.
expect(src).not.toMatch(/\b(revokeGrant|renounceGrant)\(/);
}
});
});
@@ -0,0 +1,121 @@
/**
* `POST /api/account/grants/[id]/accept` — the delegate accepts an invitation.
*
* The handshake exists because being handed read access to somebody's health
* record is not something to impose silently: an invitation confers nothing
* until the person named on it says yes, which is why `isGrantActive` fails a
* row with `acceptedAt IS NULL` and why this route is the only thing that can
* set it.
*
* The acceptance is also the consent record. `acceptedAt` and `acceptedIp` are
* stamped here and never anywhere else, on the same row that carries who
* offered the access and when it ended — one row, one history, no second
* receipt table to disagree with it.
*
* Not delegable: bare `requireAuth()` refuses under a switch, so nobody can
* accept an invitation while acting as somebody else. The delegate is the
* session user, never a body field, and `acceptGrant` puts that id in the
* `where` of a conditional update — so an invitation addressed to another
* account matches nothing and is refused as not-yours rather than accepted by
* the wrong person.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { GRANT_PARTY_SELECT, toGrantView } from "@/lib/sharing/grant-view";
import { acceptGrant, GrantError } from "@/lib/sharing/grants";
type RouteParams = { params: Promise<{ id: string }> };
/**
* Thirty a minute. Accepting is a one-shot transition per row, so the only
* traffic this bounds is somebody guessing grant ids — which the conditional
* update already refuses; the limit just stops them doing it quickly.
*/
const ACCEPT_LIMIT = 30;
const ACCEPT_WINDOW_MS = 60 * 1000;
export const POST = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const rl = await checkRateLimit(
`sharing:accept:${user.id}`,
ACCEPT_LIMIT,
ACCEPT_WINDOW_MS,
);
if (!rl.allowed) {
return apiError("Too many attempts, try again later", 429);
}
const { id } = await params;
const ip = getClientIp(request);
try {
const grant = await acceptGrant({
grantId: id,
granteeId: user.id,
ip,
});
const grantor = await prisma.user.findUniqueOrThrow({
where: { id: grant.grantorId },
select: GRANT_PARTY_SELECT,
});
// Filed under the delegate, because accepting is the delegate's own act
// and `actorUserId` NULL means exactly that. The owner's side of this
// fact is not an audit row at all — it is `acceptedAt` on the grant,
// which their panel reads.
await auditLog("sharing.grant.accepted", {
userId: user.id,
details: { grantId: grant.id, grantorId: grant.grantorId },
ipAddress: ip,
}).catch(() => {});
annotate({
action: { name: "sharing.grant.accepted" },
meta: { grant_id: grant.id },
});
return apiSuccess(toGrantView(grant, grantor));
} catch (err) {
if (err instanceof GrantError) return acceptErrorResponse(err);
throw err;
}
},
);
/**
* Why the acceptance was refused.
*
* `not_found` and `not_grantee` deliberately answer the same 404: an
* invitation addressed to somebody else is, as far as this caller is
* concerned, not there. Confirming it exists would tell a stranger that a
* particular grant id is real and that two accounts share a record.
*/
function acceptErrorResponse(err: GrantError): Response {
switch (err.code) {
case "expired":
return apiError("That invitation has lapsed", 410, {
errorCode: "sharing.accept.expired",
});
case "already_revoked":
return apiError("That invitation was withdrawn", 410, {
errorCode: "sharing.accept.revoked",
});
case "not_pending":
return apiError("That invitation was already accepted", 409, {
errorCode: "sharing.accept.not_pending",
});
default:
return apiError("Invitation not found", 404, {
errorCode: "sharing.accept.not_found",
});
}
}
@@ -0,0 +1,93 @@
/**
* `POST /api/account/grants/[id]/renounce` — the delegate hands the access back.
*
* The same transition as the owner's revoke, attributed to the other party:
* `revokedBy = GRANTEE`, so the record can tell "she withdrew it" from "he
* decided he no longer wanted it". Two facts, one column, because they are
* answers to the same question and a trail that conflated them would misread
* an ordinary handover as a fallout.
*
* Its own verb rather than a second meaning for DELETE. The owner's revoke and
* the delegate's renunciation look identical in the database and are entirely
* different acts; letting one endpoint mean both would put a `revokedBy`
* decision inside a caller-identity check, which is exactly the sort of branch
* that gets simplified later by somebody who does not know why it was there.
*
* Frictionless for the same reason as revocation, and cleaned up in the same
* transaction: every session of theirs sitting inside that record leaves with
* the grant, including the ones on other devices.
*
* Not delegable: bare `requireAuth()`, so this cannot be done while acting as
* somebody else, and the delegate is the session user rather than a body field.
* That costs a browser already inside the record one extra request — switch
* out, then renounce — and the exchange is worth it. Grant management is the
* one surface where a delegate acting as somebody else must have no reach at
* all, and "except this one endpoint, which only ever reduces" is precisely
* the kind of exception that gets widened by somebody who reads the exception
* and not the reason. Ending the access is still one click; the client makes
* the switch-back call on the way.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { GRANT_PARTY_SELECT, toGrantView } from "@/lib/sharing/grant-view";
import { GrantError, renounceGrantAndClearSwitch } from "@/lib/sharing/grants";
type RouteParams = { params: Promise<{ id: string }> };
export const POST = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
try {
const { grant, sessionsCleared } = await renounceGrantAndClearSwitch({
grantId: id,
granteeId: user.id,
});
const grantor = await prisma.user.findUniqueOrThrow({
where: { id: grant.grantorId },
select: GRANT_PARTY_SELECT,
});
await auditLog("sharing.grant.renounced", {
userId: user.id,
details: {
grantId: grant.id,
grantorId: grant.grantorId,
sessionsCleared,
},
ipAddress: getClientIp(request),
}).catch(() => {});
annotate({
action: { name: "sharing.grant.renounced" },
meta: { grant_id: grant.id, sessions_cleared: sessionsCleared },
});
return apiSuccess({
...toGrantView(grant, grantor),
sessionsCleared,
});
} catch (err) {
if (err instanceof GrantError) return renounceErrorResponse(err);
throw err;
}
},
);
function renounceErrorResponse(err: GrantError): Response {
if (err.code === "already_revoked") {
return apiError("That access has already ended", 409, {
errorCode: "sharing.renounce.already_ended",
});
}
return apiError("Grant not found", 404, {
errorCode: "sharing.renounce.not_found",
});
}
+97
View File
@@ -0,0 +1,97 @@
/**
* `DELETE /api/account/grants/[id]` — the owner withdraws access.
*
* **No step-up, no typed confirmation, no second factor, no ceremony.** That
* is a decision, not an omission: reducing access must never be harder than
* granting it was. Everything else in this product that guards a destructive
* act guards it because the act is hard to undo — this one is the undo. A
* person who wants their health record closed to somebody gets it closed on
* the first click, and if they change their mind the invitation costs one
* request to send again.
*
* Enforcement is the delegate's next request, not their next login: the
* resolver re-reads the grant every time, so seconds, not sessions. What this
* route adds on top is that the delegate's browser leaves too — the same
* transaction clears the acting-account carrier on every session of theirs
* pointing at this record, so a delegate sitting inside it lands back in their
* own account instead of on a wall of 403s under a banner still naming the
* owner.
*
* Despite the verb, nothing is deleted. `revokeGrant` stamps `revokedAt` and
* `revokedBy` on a row that stays, because "who had access, from when to when,
* and who ended it" is a question a deleted row cannot answer. DELETE is the
* right HTTP verb for "end this thing"; it is not a promise about storage.
*
* Not delegable: bare `requireAuth()` refuses under a switch, and the owner is
* the session user, so a delegate can neither revoke somebody else's grant nor
* hand their own to a third party.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { GRANT_PARTY_SELECT, toGrantView } from "@/lib/sharing/grant-view";
import { GrantError, revokeGrantAndClearSwitch } from "@/lib/sharing/grants";
type RouteParams = { params: Promise<{ id: string }> };
export const DELETE = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
try {
const { grant, sessionsCleared } = await revokeGrantAndClearSwitch({
grantId: id,
grantorId: user.id,
});
const grantee = await prisma.user.findUniqueOrThrow({
where: { id: grant.granteeId },
select: GRANT_PARTY_SELECT,
});
await auditLog("sharing.grant.revoked", {
userId: user.id,
details: {
grantId: grant.id,
granteeId: grant.granteeId,
sessionsCleared,
},
ipAddress: getClientIp(request),
}).catch(() => {});
annotate({
action: { name: "sharing.grant.revoked" },
meta: { grant_id: grant.id, sessions_cleared: sessionsCleared },
});
return apiSuccess({
...toGrantView(grant, grantee),
sessionsCleared,
});
} catch (err) {
if (err instanceof GrantError) return revokeErrorResponse(err);
throw err;
}
},
);
/**
* A grant that is not this caller's to end reads as absent, for the same
* reason it does on the accept path: a distinguishable refusal would confirm
* that a given grant id exists.
*/
function revokeErrorResponse(err: GrantError): Response {
if (err.code === "already_revoked") {
return apiError("That access has already ended", 409, {
errorCode: "sharing.revoke.already_ended",
});
}
return apiError("Grant not found", 404, {
errorCode: "sharing.revoke.not_found",
});
}
+188
View File
@@ -0,0 +1,188 @@
/**
* `/api/account/grants` — the owner's side of account sharing.
*
* `GET` lists both directions at once: the grants this account has given, and
* the grants it has received. One endpoint rather than two because they are
* one question — "who can see my record, and whose can I see" — and because a
* client that has to call twice will eventually render half an answer.
*
* `POST` offers a grant to somebody already registered on this instance.
*
* **Neither is delegable, and that is structural.** Both resolve through bare
* `requireAuth()`, which refuses outright while the caller is acting on
* another account. So a delegate cannot invite anybody into the record they
* were given access to, cannot widen their own grant, and cannot pass it on:
* the route does not have a re-delegation check that somebody could soften, it
* has no code path that runs under a switch at all. The grantor is then taken
* from the session on top of that — never from the body — so even if the mode
* changed, the grant it wrote would be the caller's own record.
*
* On the enumeration question, stated plainly rather than left implicit: an
* invitation to an unknown identifier is refused with "no such account", which
* tells the inviter whether that person exists here. That is a disclosure, it
* is deliberate, and the alternative is worse — an invite that silently goes
* nowhere for a mistyped username leaves the owner staring at a pending row
* that will never be accepted. The instance is a household, the caller is
* already authenticated, and the rate limit below bounds how fast the surface
* can be walked.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import {
GRANT_PARTY_SELECT,
toGrantView,
type GrantParty,
} from "@/lib/sharing/grant-view";
import { GrantError, inviteGrant } from "@/lib/sharing/grants";
import { inviteGrantSchema } from "@/lib/validations/account-sharing";
/**
* Ten invitations an hour, per account.
*
* Generous for the household case (nobody invites their third family member
* twice a minute) and tight enough that the account-existence disclosure above
* cannot be turned into a user-directory dump.
*/
const INVITE_LIMIT = 10;
const INVITE_WINDOW_MS = 60 * 60 * 1000;
/** How much history a list call returns per direction. */
const MAX_GRANTS_PER_DIRECTION = 100;
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "sharing.grants.list" } });
const [given, received] = await Promise.all([
prisma.accountGrant.findMany({
where: { grantorId: user.id },
orderBy: { createdAt: "desc" },
take: MAX_GRANTS_PER_DIRECTION,
include: { grantee: { select: GRANT_PARTY_SELECT } },
}),
prisma.accountGrant.findMany({
where: { granteeId: user.id },
orderBy: { createdAt: "desc" },
take: MAX_GRANTS_PER_DIRECTION,
include: { grantor: { select: GRANT_PARTY_SELECT } },
}),
]);
// Revoked and expired rows come back too. They are the consent record — "who
// had access, from when to when, and who ended it" is the question the table
// exists to answer, and a list that quietly dropped the ended rows would
// leave the panel unable to answer it. `state` says which are live.
const now = new Date();
return apiSuccess({
given: given.map((g) => toGrantView(g, g.grantee, now)),
received: received.map((g) => toGrantView(g, g.grantor, now)),
});
});
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const rl = await checkRateLimit(
`sharing:invite:${user.id}`,
INVITE_LIMIT,
INVITE_WINDOW_MS,
);
if (!rl.allowed) {
return apiError("Too many invitations, try again later", 429);
}
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 8 * 1024,
});
if (jsonError) return jsonError;
const parsed = inviteGrantSchema.safeParse(rawBody);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "sharing.invite.invalid",
});
}
const { identifier, expiresAt } = parsed.data;
// Either column, case-insensitively — the same lookup the login form does,
// so the name somebody signs in with is the name they can be invited by.
const invitee = await prisma.user.findFirst({
where: {
OR: [
{ email: { equals: identifier, mode: "insensitive" } },
{ username: { equals: identifier, mode: "insensitive" } },
],
},
select: GRANT_PARTY_SELECT,
});
if (!invitee) {
return apiError("No account with that name or e-mail", 404);
}
try {
// `inviteGrant` decides everything about the row: READ and only READ, the
// self-grant refusal, and the one-live-grant-per-pair rule the partial
// unique index backs. None of it is re-decided here.
const grant = await inviteGrant({
grantorId: user.id,
granteeId: invitee.id,
expiresAt: expiresAt ? new Date(expiresAt) : null,
});
await auditLog("sharing.grant.invited", {
userId: user.id,
details: { grantId: grant.id, granteeId: invitee.id },
ipAddress: getClientIp(request),
}).catch(() => {});
annotate({
action: { name: "sharing.grant.invited" },
meta: {
grant_id: grant.id,
expires: expiresAt !== null && expiresAt !== undefined,
},
});
return apiSuccess(toGrantView(grant, invitee as GrantParty), 201);
} catch (err) {
if (err instanceof GrantError) return grantErrorResponse(err);
throw err;
}
});
/**
* A refused transition, as an HTTP answer.
*
* The mapping lives here rather than inside the domain module because status
* codes are a transport concern and the state machine is not — it hands back a
* stable code and lets each consumer say what that means on its own wire.
*/
function grantErrorResponse(err: GrantError): Response {
switch (err.code) {
case "self_grant":
return apiError("An account cannot be shared with itself", 422, {
errorCode: "sharing.invite.self",
});
case "duplicate_live_grant":
return apiError("That account already has access", 409, {
errorCode: "sharing.invite.duplicate",
});
default:
return apiError("Invitation refused", 422, {
errorCode: "sharing.invite.refused",
});
}
}
+129
View File
@@ -0,0 +1,129 @@
/**
* `POST /api/account/switch` — point this browser at a record, or back at its own.
*
* Cookie transport only. The Bearer carrier is a per-request header, and that
* difference is not an inconsistency to be smoothed over: stamping a switch on
* a long-lived token would make the credential itself ambient authority, and
* the token has to keep meaning "this person" for as long as it exists. A
* Bearer caller reaching this endpoint has misunderstood its own transport, so
* it is refused rather than quietly ignored.
*
* An actor surface — `requireActorAuth()` — and it has to be, because this is
* the way back. Under the fail-closed default every route refuses while a
* switch is on; if this one did too, a switched browser could enter a record
* and never leave it, which turns a read-only convenience into a trap. So the
* mode is declared, and the declaration is narrow: the only row this handler
* touches is the caller's own session.
*
* What it writes is a SELECTOR, not a permission. The grant is validated here
* before the stamp so a client gets an immediate, honest refusal instead of a
* switch that appears to work and 403s on the next page — but the stamp itself
* authorises nothing, and the resolver re-checks the grant on every request
* that follows. A value stranded here by a revocation seconds later confers
* exactly nothing.
*/
import { NextRequest } from "next/server";
import {
apiHandler,
requireActorAuth,
SharingAccessDeniedError,
} from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { pointSessionAt } from "@/lib/sharing/acting-session";
import { findActiveGrant } from "@/lib/sharing/grants";
import { switchAccountSchema } from "@/lib/validations/account-sharing";
export const POST = apiHandler(async (request: NextRequest) => {
const auth = await requireActorAuth();
if (auth.authMethod !== "cookie") {
return apiError(
"Account switching is a browser-session feature; send the account selector header instead",
400,
{ errorCode: "sharing.switch.wrong_transport" },
);
}
const { data: rawBody, error: jsonError } = await safeJson(request, {
maxBytes: 4 * 1024,
});
if (jsonError) return jsonError;
const parsed = switchAccountSchema.safeParse(rawBody);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "sharing.switch.invalid",
});
}
const { accountId } = parsed.data;
const ip = getClientIp(request);
if (accountId === null) {
await pointSessionAt(auth.session.id, auth.user.id, null);
await auditLog("sharing.switch.off", {
userId: auth.user.id,
details: {},
ipAddress: ip,
}).catch(() => {});
annotate({ action: { name: "sharing.switch.off" } });
return apiSuccess({ actingAs: null });
}
// The same predicate the resolver uses, on the same table, with no second
// idea of "active" anywhere in this file. If this route decided for itself,
// it would drift from the resolver in exactly one direction: a switch the
// panel accepts and the next request refuses, or worse, the reverse.
const grant = await findActiveGrant({
grantorId: accountId,
granteeId: auth.user.id,
});
if (!grant) {
// The flat refusal, with no reason on the wire. An account that does not
// exist and an account that granted nothing are the same empty row from
// the same query, so a caller learns nothing about who has an account here.
await auditLog("sharing.access.denied", {
userId: auth.user.id,
details: { reason: "switch_no_active_grant", target: accountId },
ipAddress: ip,
}).catch(() => {});
throw new SharingAccessDeniedError();
}
const owner = await prisma.user.findUniqueOrThrow({
where: { id: grant.grantorId },
select: { id: true, username: true, displayName: true },
});
await pointSessionAt(auth.session.id, auth.user.id, owner.id);
await auditLog("sharing.switch.on", {
userId: auth.user.id,
details: { grantId: grant.id, grantorId: owner.id },
ipAddress: ip,
}).catch(() => {});
annotate({
action: { name: "sharing.switch.on" },
meta: { grant_id: grant.id },
});
return apiSuccess({
actingAs: {
accountId: owner.id,
username: owner.username,
displayName: owner.displayName,
access: grant.access,
},
});
});
+330
View File
@@ -0,0 +1,330 @@
/**
* OpenAPI route table — account sharing (v1.36.0).
*
* The lifecycle of one account's standing permission to read another's record:
* the owner invites, the delegate accepts, either side ends it, and a browser
* points itself at a record it has been granted.
*
* Two contract facts a client has to know and cannot derive:
*
* * `POST /api/account/switch` is a browser-session feature. The Bearer
* transport carries its account selector in the `X-HealthLog-Account`
* header, per request, and gets a 400 from this endpoint — a token must
* never accumulate switch state, because the credential has to keep
* meaning "this person" for as long as it exists.
* * Every route here refuses while the caller is acting on another account
* (403, `meta.errorCode: "sharing.not_permitted"`), with the single
* exception of the switch endpoint, which is the way back out. A delegate
* cannot invite, widen, transfer, or end anybody's access from inside a
* record they were given.
*
* Part of the OpenAPI route table; aggregated in `./index.ts`.
*/
import type { ZodOpenApiObject } from "zod-openapi";
import { z } from "zod/v4";
import {
inviteGrantSchema,
switchAccountSchema,
} from "@/lib/validations/account-sharing";
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
inviteGrantSchema.meta({
id: "AccountGrantInvite",
description:
"Offer read access to another account on this instance. `identifier` is the invitee's username or e-mail, matched case-insensitively — the same identifier they sign in with. `expiresAt` is optional; omitted or null means the grant runs until somebody ends it. The access level is not a parameter: v1 grants READ only.",
});
switchAccountSchema.meta({
id: "AccountSwitchRequest",
description:
"Point this browser session at an account, or (with `accountId: null`) back at its own. Cookie transport only.",
});
const grantParty = z
.object({
id: z.string(),
username: z.string(),
displayName: z.string().nullable(),
})
.meta({
id: "AccountGrantParty",
description:
"The account on the other end of a grant. E-mail addresses are deliberately not published: a grant is not a way to collect the other party's contact details.",
});
const grantState = z.enum(["PENDING", "ACTIVE", "EXPIRED", "REVOKED"]).meta({
id: "AccountGrantState",
description:
"What the grant is right now, resolved server-side against the request clock. Only ACTIVE confers anything. Ordering is stated rather than implied: a revoked grant reads REVOKED even if it also sat past its expiry, and an unaccepted invitation past its expiry reads EXPIRED rather than PENDING because it can no longer be accepted. Clients render this value and never re-derive it from the timestamps.",
});
const grantView = z
.object({
id: z.string(),
account: grantParty,
access: z.enum(["READ", "WRITE"]),
state: grantState,
invitedAt: z.string(),
acceptedAt: z.string().nullable(),
expiresAt: z.string().nullable(),
lastUsedAt: z.string().nullable(),
revokedAt: z.string().nullable(),
revokedBy: z.enum(["GRANTOR", "GRANTEE"]).nullable(),
})
.meta({
id: "AccountGrant",
description:
"One grant, from either side. The row is also the consent record — who offered the access, when it was accepted, when and by whom it ended — so ended grants stay in the list with `state: REVOKED` rather than disappearing. `revokedBy` distinguishes the owner withdrawing access from the delegate handing it back. `lastUsedAt` mirrors API-token semantics: the last time the grant was actually exercised.",
});
const endedGrantView = grantView
.extend({
sessionsCleared: z
.number()
.int()
.describe(
"How many of the delegate's browser sessions were sitting inside the record and were put back into their own account, in the same transaction that ended the grant.",
),
})
.meta({
id: "EndedAccountGrant",
description:
"A grant that has just been ended, plus what the cleanup did. Nothing is deleted: the row survives as the consent record and DELETE names the act, not the storage.",
});
const grantListResponse = z
.object({
given: z.array(grantView),
received: z.array(grantView),
})
.meta({
id: "AccountGrantList",
description:
"Both directions at once: `given` are the grants this account has offered on its own record, `received` are the ones offered to it. Capped at 100 rows per direction, newest first.",
});
const switchResponse = z
.object({
actingAs: z
.object({
accountId: z.string(),
username: z.string(),
displayName: z.string().nullable(),
access: z.enum(["READ", "WRITE"]),
})
.nullable(),
})
.meta({
id: "AccountSwitchState",
description:
"The account this session is now acting on, or null when it is back in its own. The stamp is a selector and not a permission — the grant is re-checked on every subsequent request, so a revocation lands on the next one rather than at the next login.",
});
const sharingRefusal = {
description:
"Refused. `meta.errorCode` is `sharing.not_permitted` when the request was made while acting on another account (grant management is never delegable), or `sharing.access.denied` when the caller may not act as the account they named. The latter is byte-identical for an account that does not exist and one that granted nothing — the refusal is not an enumeration oracle.",
content: { "application/json": { schema: errorEnvelope } },
};
export const accountSharingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/account/grants": {
get: {
tags: ["Account sharing"],
summary: "List grants in both directions",
description:
"Every grant this account has given or received, ended ones included. Refused while acting on another account.",
responses: {
...stdResponses,
"200": {
description: "Grants given and received.",
content: {
"application/json": {
schema: dataEnvelope(
grantListResponse,
"AccountGrantListEnvelope",
),
},
},
},
"403": sharingRefusal,
},
},
post: {
tags: ["Account sharing"],
summary: "Invite an account to read this record",
description:
"Offers read access to somebody already registered on this instance; the grant confers nothing until they accept it. An identifier that names no account answers 404 — a deliberate disclosure to an authenticated caller on a household instance, rate-limited to 10 invitations an hour, and the alternative (a silent pending row for a mistyped username) is worse. Refused while acting on another account, so a delegate can neither invite nor re-delegate.",
requestBody: {
required: true,
content: { "application/json": { schema: inviteGrantSchema } },
},
responses: {
...stdResponses,
"201": {
description: "The pending grant.",
content: {
"application/json": {
schema: dataEnvelope(grantView, "AccountGrantEnvelope"),
},
},
},
"403": sharingRefusal,
"404": {
description: "No account on this instance carries that identifier.",
content: { "application/json": { schema: errorEnvelope } },
},
"409": {
description:
"That account already holds a live grant on this record (`meta.errorCode: sharing.invite.duplicate`). Re-inviting somebody whose access was revoked succeeds and mints a new row; the old one stays as history.",
content: { "application/json": { schema: errorEnvelope } },
},
"422": {
description:
"Validation failed, or the invitation named the caller's own account (`meta.errorCode: sharing.invite.self`).",
content: { "application/json": { schema: errorEnvelope } },
},
"429": {
description: "Invitation rate limit exceeded.",
content: { "application/json": { schema: errorEnvelope } },
},
},
},
},
"/api/account/grants/{id}/accept": {
post: {
tags: ["Account sharing"],
summary: "Accept an invitation",
description:
"The delegate's own act, and the consent record: it stamps the acceptance time and the accepting IP on the grant row. One-shot and delegate-only — the invited account comes from the session, so an invitation addressed to somebody else answers 404 rather than confirming it exists.",
requestParams: {
path: z.object({ id: z.string() }),
},
responses: {
...stdResponses,
"200": {
description: "The now-active grant.",
content: {
"application/json": {
schema: dataEnvelope(grantView, "AcceptedAccountGrantEnvelope"),
},
},
},
"403": sharingRefusal,
"404": {
description: "No such invitation for this account.",
content: { "application/json": { schema: errorEnvelope } },
},
"409": {
description: "Already accepted.",
content: { "application/json": { schema: errorEnvelope } },
},
"410": {
description: "The invitation lapsed or was withdrawn.",
content: { "application/json": { schema: errorEnvelope } },
},
},
},
},
"/api/account/grants/{id}": {
delete: {
tags: ["Account sharing"],
summary: "Withdraw access (owner)",
description:
"Ends a grant on the caller's own record. No step-up and no confirmation step — reducing access must never be harder than granting it. Enforcement is the delegate's next request, not their next login, and the same transaction puts every browser session of theirs that was inside the record back into their own account. Nothing is deleted: the row survives with `revokedAt` and `revokedBy: GRANTOR`.",
requestParams: {
path: z.object({ id: z.string() }),
},
responses: {
...stdResponses,
"200": {
description: "The ended grant, and what the session cleanup did.",
content: {
"application/json": {
schema: dataEnvelope(
endedGrantView,
"RevokedAccountGrantEnvelope",
),
},
},
},
"403": sharingRefusal,
"404": {
description: "No such grant on this account's record.",
content: { "application/json": { schema: errorEnvelope } },
},
"409": {
description: "That access had already ended.",
content: { "application/json": { schema: errorEnvelope } },
},
},
},
},
"/api/account/grants/{id}/renounce": {
post: {
tags: ["Account sharing"],
summary: "Hand access back (delegate)",
description:
"The same transition as the owner's revoke, attributed the other way (`revokedBy: GRANTEE`) so the record can tell a withdrawal from a handover. Same session cleanup. Refused while acting on the record being renounced — switch back first (one request), which keeps grant management non-delegable without exceptions.",
requestParams: {
path: z.object({ id: z.string() }),
},
responses: {
...stdResponses,
"200": {
description: "The ended grant, and what the session cleanup did.",
content: {
"application/json": {
schema: dataEnvelope(
endedGrantView,
"RenouncedAccountGrantEnvelope",
),
},
},
},
"403": sharingRefusal,
"404": {
description: "No such grant held by this account.",
content: { "application/json": { schema: errorEnvelope } },
},
"409": {
description: "That access had already ended.",
content: { "application/json": { schema: errorEnvelope } },
},
},
},
},
"/api/account/switch": {
post: {
tags: ["Account sharing"],
summary: "Act on a granted record, or return to your own",
description:
"Cookie transport only; a Bearer caller gets 400 (`meta.errorCode: sharing.switch.wrong_transport`) and should send the `X-HealthLog-Account` header on the requests it wants scoped instead. The grant is validated here so the client gets an honest refusal immediately, but the stamp authorises nothing on its own — every following request re-checks the grant. `accountId: null` clears the switch and always works, including from inside a switched session: this endpoint is the way out.",
requestBody: {
required: true,
content: { "application/json": { schema: switchAccountSchema } },
},
responses: {
...stdResponses,
"200": {
description: "The account this session is now acting on, or null.",
content: {
"application/json": {
schema: dataEnvelope(switchResponse, "AccountSwitchEnvelope"),
},
},
},
"400": {
description:
"Sent over the Bearer transport, which carries its selector per request instead.",
content: { "application/json": { schema: errorEnvelope } },
},
"403": sharingRefusal,
"422": {
description: "Validation failed.",
content: { "application/json": { schema: errorEnvelope } },
},
},
},
},
};
+4
View File
@@ -21,6 +21,7 @@
*/
import type { ZodOpenApiObject } from "zod-openapi";
import { accountSharingPaths } from "./account-sharing";
import { adminDiagnosticPaths, adminInvitePaths } from "./admin";
import { allergyPaths } from "./allergies";
import { authPaths } from "./auth";
@@ -115,6 +116,9 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
// registry entirely, now documented as the reference optimistic-concurrency
// contract (appended, spread order is load-bearing).
...dashboardWidgetPaths,
// v1.36.0 — account sharing: the grant lifecycle and the switch endpoint
// (appended, spread order is load-bearing).
...accountSharingPaths,
};
export const openApiComponents: NonNullable<ZodOpenApiObject["components"]> = {
+74
View File
@@ -0,0 +1,74 @@
/**
* v1.36.0 — the only writer of `Session.actingAsUserId`.
*
* The column is the cookie transport's acting-account carrier. Two things
* write it and they are opposite acts: the switch endpoint points a browser
* session at a record, and the end of a grant points every affected session
* back at its own. Both live here rather than at their call sites so the
* carrier has one writer, the same way it has one reader (the resolver in
* `src/lib/api-handler.ts`). The structural guard in
* `src/__tests__/acting-account-boundary-guard.test.ts` holds that shape: any
* other file that so much as names the column fails it.
*
* Nothing here authorises anything. `pointSessionAt` writes an id its caller
* has already proven, and the resolver re-checks the grant on every request
* regardless — a value stranded here by a grant that has since ended confers
* exactly nothing. The clearing below is therefore housekeeping, not
* enforcement: it exists so a delegate's browser lands back in its own account
* instead of on a wall of 403s. Enforcement already happened when the grant
* row was stamped.
*/
import { prisma } from "@/lib/db";
import type { Prisma } from "@/generated/prisma/client";
/**
* The narrow client slice these functions need, so a caller can hand them a
* transaction handle. Same shape as `grants.ts` uses for `accountGrant`.
*/
type SessionDb = Pick<Prisma.TransactionClient, "session">;
/**
* Point one browser session at an account's record, or (with `null`) back at
* its own.
*
* Scoped by session id AND the caller's own user id. The id alone would be
* enough in every code path that exists today; pinning the owner as well means
* a future caller that gets a session id from somewhere less trustworthy than
* `requireActorAuth` cannot use this to steer somebody else's browser.
*/
export async function pointSessionAt(
sessionId: string,
userId: string,
actingAsUserId: string | null,
db: SessionDb = prisma,
): Promise<void> {
await db.session.updateMany({
where: { id: sessionId, userId },
data: { actingAsUserId },
});
}
/**
* Drop every session of `granteeId` that is currently acting as `grantorId`.
*
* Called inside the transaction that ends a grant, which is the point: the
* grant row and the sessions pointing at it stop being true in one atomic
* step. Split across two statements, a revocation that failed halfway would
* leave a browser sitting inside a record it no longer has access to — every
* request refused, the banner still claiming otherwise, and nothing in the UI
* able to explain it.
*
* Returns the number of sessions cleared, so the caller can put it on the wide
* event: "revoked, and dropped her out of it" is a different fact from
* "revoked", and the owner pressing the button deserves to know which happened.
*/
export async function clearActingSessions(
pair: { grantorId: string; granteeId: string },
db: SessionDb = prisma,
): Promise<number> {
const { count } = await db.session.updateMany({
where: { userId: pair.granteeId, actingAsUserId: pair.grantorId },
data: { actingAsUserId: null },
});
return count;
}
+68
View File
@@ -0,0 +1,68 @@
/**
* v1.36.0 — how a grant row reaches a client.
*
* One shape for every sharing route, because five endpoints each shaping the
* same row their own way is five chances for the panel to render "active" on a
* row the resolver has already stopped honouring.
*
* `state` is the important field and it is not stored anywhere: it comes from
* `grantState()`, the same function the resolver's own predicate is built on.
* A serialiser that derived it here from `revokedAt`/`acceptedAt` would be the
* second decider this feature spends most of its design avoiding — and it
* would drift in the dangerous direction, showing a grant as ended while the
* resolver still admitted it, or the reverse.
*
* What is NOT published: the other party's e-mail. The owner typed an
* identifier to invite somebody and does not need it echoed; the delegate
* never asked for the owner's. Handing every party to a grant a durable
* address for every other party is reach the grant was never meant to confer.
*/
import { grantState, type GrantState } from "@/lib/sharing/grants";
import type { AccountGrant } from "@/generated/prisma/client";
/** The other party to a grant, as far as anyone is told about them. */
export interface GrantParty {
id: string;
username: string;
displayName: string | null;
}
export interface GrantView {
id: string;
/** The account on the other end: the delegate on a given grant, the owner on a received one. */
account: GrantParty;
access: AccountGrant["access"];
state: GrantState;
invitedAt: string;
acceptedAt: string | null;
expiresAt: string | null;
lastUsedAt: string | null;
revokedAt: string | null;
revokedBy: AccountGrant["revokedBy"];
}
/** The columns a party needs, so a caller can `select` exactly them. */
export const GRANT_PARTY_SELECT = {
id: true,
username: true,
displayName: true,
} as const;
export function toGrantView(
grant: AccountGrant,
account: GrantParty,
now: Date = new Date(),
): GrantView {
return {
id: grant.id,
account,
access: grant.access,
state: grantState(grant, now),
invitedAt: grant.invitedAt.toISOString(),
acceptedAt: grant.acceptedAt?.toISOString() ?? null,
expiresAt: grant.expiresAt?.toISOString() ?? null,
lastUsedAt: grant.lastUsedAt?.toISOString() ?? null,
revokedAt: grant.revokedAt?.toISOString() ?? null,
revokedBy: grant.revokedBy,
};
}
+72
View File
@@ -45,6 +45,7 @@
*/
import { prisma } from "@/lib/db";
import { isP2002 } from "@/lib/prisma-errors";
import { clearActingSessions } from "@/lib/sharing/acting-session";
import type {
AccountGrant,
AccountGrantAccess,
@@ -292,6 +293,57 @@ export async function renounceGrant(
return endGrant(input.grantId, { granteeId: input.granteeId }, "GRANTEE", db);
}
/**
* What ending a grant did: the row as it now stands, and how many of the
* delegate's browser sessions were sitting inside the record when it happened.
*/
export interface EndedGrant {
grant: AccountGrant;
sessionsCleared: number;
}
/**
* The owner withdraws access, and any browser already inside the record leaves
* with it.
*
* This is the verb the sharing panel calls; {@link revokeGrant} is the state
* transition underneath it. They are separate because the transition has to be
* usable inside somebody else's transaction, and because a caller who takes the
* transition alone should have to notice they are leaving sessions behind.
*
* One transaction, and the reason is a specific failure: the grant row and the
* sessions pointing at it are two statements of the same fact, and a revocation
* that stamped the row but not the sessions would leave the delegate's browser
* inside a record it can no longer read — every request 403, the banner still
* saying "viewing her record", nothing on screen able to account for it. The
* resolver would refuse correctly and the person would still be looking at a
* lie.
*
* Still no step-up, no confirmation ceremony, nothing that makes this slower
* than granting was. Reducing access must never be harder than giving it: the
* person ending access to their own health record is the one party here whose
* intent needs no verification.
*/
export async function revokeGrantAndClearSwitch(
input: RevokeGrantInput,
): Promise<EndedGrant> {
return endAndClear((tx) => revokeGrant(input, tx));
}
/**
* The delegate hands the access back, and their own browser leaves the record.
*
* Same cleanup, and for a sharper reason than on the revoke side: the session
* being cleared is almost certainly the one making this very request, because
* renouncing is a thing people do while looking at the record they are
* renouncing.
*/
export async function renounceGrantAndClearSwitch(
input: RenounceGrantInput,
): Promise<EndedGrant> {
return endAndClear((tx) => renounceGrant(input, tx));
}
// ── Resolution ──────────────────────────────────────────────────────────────
/**
@@ -342,6 +394,26 @@ export function touchGrantUsage(
// ── Internals ───────────────────────────────────────────────────────────────
/**
* Run an ending transition and its session cleanup as one unit.
*
* The transition runs first and throws on a refusal, which rolls the whole
* thing back — so a revoke somebody was not entitled to make cannot clear a
* session as a side effect of being refused.
*/
async function endAndClear(
transition: (tx: Prisma.TransactionClient) => Promise<AccountGrant>,
): Promise<EndedGrant> {
return prisma.$transaction(async (tx) => {
const grant = await transition(tx);
const sessionsCleared = await clearActingSessions(
{ grantorId: grant.grantorId, granteeId: grant.granteeId },
tx,
);
return { grant, sessionsCleared };
});
}
/** The shared body of revoke and renounce. */
async function endGrant(
grantId: string,
+60
View File
@@ -0,0 +1,60 @@
/**
* v1.36.0 — request shapes for the account-sharing lifecycle.
*
* Four verbs are exercised through these routes (invite, accept, end, switch)
* and only two of them carry a body worth validating. What is NOT here is as
* deliberate as what is:
*
* * No `access` level. v1 grants READ and only READ, and the level is not a
* parameter anywhere in the stack — `inviteGrant` hardcodes it. A field
* here would be an input the domain module then ignores, which is the
* shape of a promise nobody kept.
* * No `userId`, `grantorId` or `granteeId` on any body. Both sides of a
* grant come from the authenticated session and the row itself. A body
* field naming a party would be an authorization decision made by the
* caller.
*/
import { z } from "zod/v4";
/**
* How long a value naming an account may be.
*
* Matches the resolver's own selector bound (`MAX_SELECTOR_LENGTH`, 64): ids
* are 25-character cuids, and a longer string names nothing that could exist.
*/
const MAX_ACCOUNT_ID_LENGTH = 64;
/**
* An e-mail address or a username, whichever the owner knows the person by.
*
* The same identifier the login form accepts, matched the same way (either
* column, case-insensitively), because asking somebody to invite a housemate
* by a different name than the one that housemate types to sign in is a
* support ticket waiting to happen.
*
* The 255 bound is the e-mail column's practical ceiling; anything past it is
* refused before it reaches a query.
*/
export const inviteGrantSchema = z.object({
identifier: z.string().trim().min(1).max(255),
/**
* Optional lapse date. Absent or null means the grant runs until somebody
* ends it — the common household case, where an expiry the owner has to
* remember to renew is worse than one that does not exist.
*/
expiresAt: z.iso.datetime({ offset: true }).nullable().optional(),
});
export type InviteGrantBody = z.infer<typeof inviteGrantSchema>;
/**
* The switch body. `null` is the whole point of the field rather than an
* omission: clearing the switch and setting one are the same act pointed in
* two directions, and a separate "unswitch" route would be a second place to
* get the session write right.
*/
export const switchAccountSchema = z.object({
accountId: z.string().min(1).max(MAX_ACCOUNT_ID_LENGTH).nullable(),
});
export type SwitchAccountBody = z.infer<typeof switchAccountSchema>;
+616
View File
@@ -0,0 +1,616 @@
/**
* The five verbs of account sharing, over the real routes, against real
* Postgres.
*
* Invite, accept, switch, revoke, renounce. Every one of them is a question
* about which rows exist afterwards, which is why none of it is unit-tested
* here: a mocked Prisma can show that the route called what it meant to call,
* and this feature has exactly one failure mode worth catching — the call that
* was made and did not land. The revocation cleanup is the sharpest case. A
* response body saying `sessionsCleared: 1` proves nothing; the session row
* still pointing at the owner's record is the defect.
*
* The delegable read handler is assembled here rather than imported because no
* shipped route declares `requireRecordAuth` yet — the frozen list lands empty
* on purpose and routes migrate into it later. Everything beneath it is real:
* the real wrapper, the real resolver, the real grant module, the real
* database.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest, NextResponse } from "next/server";
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: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { hashToken } from "@/lib/auth/hmac";
import { prisma } from "@/lib/db";
let counter = 0;
async function makeUser(label: string) {
const suffix = `${label}-${counter++}`;
return getPrismaClient().user.create({
data: {
username: `share-${suffix}`,
email: `share-${suffix}@example.test`,
displayName: `Share ${suffix}`,
role: "USER",
},
});
}
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;
}
async function mintToken(userId: string): Promise<string> {
const raw = `hlk_${userId}-${counter++}`.padEnd(20, "0");
await getPrismaClient().apiToken.create({
data: {
userId,
name: "sharing-lifecycle-test",
tokenHash: hashToken(raw),
permissions: ["*"],
},
});
return raw;
}
function jsonRequest(url: string, method: string, body?: unknown): NextRequest {
return new NextRequest(url, {
method,
headers: { "content-type": "application/json" },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
}
// ── the routes under test ───────────────────────────────────────────────────
async function listGrants(): Promise<Response> {
const { GET } = await import("@/app/api/account/grants/route");
return GET();
}
async function invite(
identifier: string,
expiresAt?: string | null,
): Promise<Response> {
const { POST } = await import("@/app/api/account/grants/route");
return POST(
jsonRequest("http://localhost/api/account/grants", "POST", {
identifier,
...(expiresAt === undefined ? {} : { expiresAt }),
}),
);
}
async function accept(grantId: string): Promise<Response> {
const { POST } = await import("@/app/api/account/grants/[id]/accept/route");
return POST(
jsonRequest(
`http://localhost/api/account/grants/${grantId}/accept`,
"POST",
),
{ params: Promise.resolve({ id: grantId }) },
);
}
async function revoke(grantId: string): Promise<Response> {
const { DELETE } = await import("@/app/api/account/grants/[id]/route");
return DELETE(
jsonRequest(`http://localhost/api/account/grants/${grantId}`, "DELETE"),
{ params: Promise.resolve({ id: grantId }) },
);
}
async function renounce(grantId: string): Promise<Response> {
const { POST } = await import("@/app/api/account/grants/[id]/renounce/route");
return POST(
jsonRequest(
`http://localhost/api/account/grants/${grantId}/renounce`,
"POST",
),
{ params: Promise.resolve({ id: grantId }) },
);
}
async function switchTo(accountId: string | null): Promise<Response> {
const { POST } = await import("@/app/api/account/switch/route");
return POST(
jsonRequest("http://localhost/api/account/switch", "POST", { accountId }),
);
}
/** A delegable read, exactly as a migrated route builds one. */
const delegableRead: (request: NextRequest) => Promise<Response> = apiHandler(
async () => {
const { user, actor } = await requireRecordAuth("read");
const rows = await prisma.measurement.findMany({
where: { userId: user.id },
orderBy: { value: "asc" },
});
return NextResponse.json({
data: {
scopeUserId: user.id,
actorUserId: actor.id,
values: rows.map((r) => r.value),
},
error: null,
});
},
);
function readRecord(): Promise<Response> {
return delegableRead(
new NextRequest("http://localhost/api/test/delegable", { method: "GET" }),
);
}
async function seedWeight(userId: string, value: number) {
return getPrismaClient().measurement.create({
data: {
userId,
type: "WEIGHT",
value,
unit: "kg",
measuredAt: new Date(),
source: "MANUAL",
},
});
}
/** The acting-account column on one session row, read straight from the table. */
async function actingAsOf(sessionId: string): Promise<string | null> {
const row = await getPrismaClient().session.findUniqueOrThrow({
where: { id: sessionId },
select: { actingAsUserId: true },
});
return row.actingAsUserId;
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
describe("the handshake, end to end", () => {
it("carries a household from invitation to revocation and leaves the right rows", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await seedWeight(owner.id, 81);
await seedWeight(delegate.id, 64);
// ── the owner invites ──
await signIn(owner.id);
const invited = await invite(delegate.username);
expect(invited.status).toBe(201);
const grantView = (await invited.json()).data;
expect(grantView.state).toBe("PENDING");
expect(grantView.access).toBe("READ");
expect(grantView.account.id).toBe(delegate.id);
// Pending confers nothing: the delegate cannot switch into a record they
// have only been offered.
const delegateSession = await signIn(delegate.id);
expect((await switchTo(owner.id)).status).toBe(403);
expect(await actingAsOf(delegateSession.id)).toBeNull();
// ── the delegate accepts ──
const accepted = await accept(grantView.id);
expect(accepted.status).toBe(200);
expect((await accepted.json()).data.state).toBe("ACTIVE");
const acceptedRow = await getPrismaClient().accountGrant.findUniqueOrThrow({
where: { id: grantView.id },
});
expect(acceptedRow.acceptedAt).not.toBeNull();
// ── the delegate switches in and reads the owner's record ──
const switched = await switchTo(owner.id);
expect(switched.status).toBe(200);
expect((await switched.json()).data.actingAs.accountId).toBe(owner.id);
expect(await actingAsOf(delegateSession.id)).toBe(owner.id);
const read = await readRecord();
expect(read.status).toBe(200);
const readBody = (await read.json()).data;
expect(readBody.scopeUserId).toBe(owner.id);
expect(readBody.actorUserId).toBe(delegate.id);
// The delegate's own 64 kg row is seeded and must not appear. An empty
// list would satisfy a weaker assertion while hiding the exact failure
// this feature exists to avoid.
expect(readBody.values).toEqual([81]);
// ── the owner revokes ──
await signIn(owner.id);
const revoked = await revoke(grantView.id);
expect(revoked.status).toBe(200);
expect((await revoked.json()).data.sessionsCleared).toBe(1);
// The row survives as the consent record rather than being deleted.
const revokedRow = await getPrismaClient().accountGrant.findUniqueOrThrow({
where: { id: grantView.id },
});
expect(revokedRow.revokedAt).not.toBeNull();
expect(revokedRow.revokedBy).toBe("GRANTOR");
expect(revokedRow.acceptedAt).not.toBeNull();
// The session row, not the response body. A cleanup that ran everywhere
// except the database would look identical from the outside.
expect(await actingAsOf(delegateSession.id)).toBeNull();
// And the delegate's next request is refused on the grant, independently
// of the session having been cleared.
cookieJar.set("healthlog_session", delegateSession.id);
await getPrismaClient().session.update({
where: { id: delegateSession.id },
data: { actingAsUserId: owner.id },
});
const afterRevoke = await readRecord();
expect(afterRevoke.status).toBe(403);
expect((await afterRevoke.json()).meta.errorCode).toBe(
"sharing.access.denied",
);
});
it("drops the delegate's other sessions when they renounce", async () => {
// Two browsers, the way a person actually has them: a phone sitting inside
// the owner's record and a laptop in their own account. Renouncing from
// the laptop has to evict the phone — a cleanup that only reached the
// session making the request would leave the other one inside a record
// whose grant no longer exists.
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await signIn(owner.id);
const grantId = (await (await invite(delegate.email!)).json()).data.id;
const phone = await signIn(delegate.id);
await accept(grantId);
await switchTo(owner.id);
expect(await actingAsOf(phone.id)).toBe(owner.id);
const laptop = await signIn(delegate.id);
const gone = await renounce(grantId);
expect(gone.status).toBe(200);
expect((await gone.json()).data.sessionsCleared).toBe(1);
expect(await actingAsOf(phone.id)).toBeNull();
expect(await actingAsOf(laptop.id)).toBeNull();
const row = await getPrismaClient().accountGrant.findUniqueOrThrow({
where: { id: grantId },
});
expect(row.revokedBy).toBe("GRANTEE");
});
it("refuses a renunciation made from inside the record itself", async () => {
// Grant management is non-delegable without exception, and the delegate's
// own renounce is not carved out of that. The browser switches back first
// — one request, which the client makes on the way — and the fail-closed
// default stays a default rather than a default with an exception.
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await signIn(owner.id);
const grantId = (await (await invite(delegate.username)).json()).data.id;
const session = await signIn(delegate.id);
await accept(grantId);
await switchTo(owner.id);
const refused = await renounce(grantId);
expect(refused.status).toBe(403);
expect((await refused.json()).meta.errorCode).toBe("sharing.not_permitted");
// Out of the record, then out of the grant, both instant.
expect((await switchTo(null)).status).toBe(200);
expect((await renounce(grantId)).status).toBe(200);
expect(await actingAsOf(session.id)).toBeNull();
const row = await getPrismaClient().accountGrant.findUniqueOrThrow({
where: { id: grantId },
});
expect(row.revokedBy).toBe("GRANTEE");
});
it("clears only the sessions pointing at the record that ended", async () => {
// Two owners, one delegate with a browser in each. Ending one grant must
// not evict the other — a cleanup scoped to the delegate alone would pass
// every single-household test and quietly sign people out of a record
// nobody revoked.
const ownerA = await makeUser("owner-a");
const ownerB = await makeUser("owner-b");
const delegate = await makeUser("delegate");
await signIn(ownerA.id);
const grantA = (await (await invite(delegate.username)).json()).data.id;
await signIn(ownerB.id);
const grantB = (await (await invite(delegate.username)).json()).data.id;
const sessionA = await signIn(delegate.id);
await accept(grantA);
await switchTo(ownerA.id);
const sessionB = await signIn(delegate.id);
await accept(grantB);
await switchTo(ownerB.id);
await signIn(ownerA.id);
await revoke(grantA);
expect(await actingAsOf(sessionA.id)).toBeNull();
expect(await actingAsOf(sessionB.id)).toBe(ownerB.id);
});
});
describe("who may do what to a grant", () => {
async function pendingGrant() {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await signIn(owner.id);
const id = (await (await invite(delegate.username)).json()).data.id;
return { owner, delegate, id };
}
it("refuses an acceptance by anybody but the invited account", async () => {
const { id } = await pendingGrant();
const stranger = await makeUser("stranger");
await signIn(stranger.id);
const response = await accept(id);
expect(response.status).toBe(404);
const row = await getPrismaClient().accountGrant.findUniqueOrThrow({
where: { id },
});
expect(row.acceptedAt).toBeNull();
});
it("refuses a revocation by the delegate — renounce is their verb", async () => {
const { delegate, id } = await pendingGrant();
await signIn(delegate.id);
expect((await revoke(id)).status).toBe(404);
const row = await getPrismaClient().accountGrant.findUniqueOrThrow({
where: { id },
});
expect(row.revokedAt).toBeNull();
});
it("refuses a renunciation by the owner — revoke is theirs", async () => {
const { owner, id } = await pendingGrant();
await signIn(owner.id);
expect((await renounce(id)).status).toBe(404);
const row = await getPrismaClient().accountGrant.findUniqueOrThrow({
where: { id },
});
expect(row.revokedAt).toBeNull();
});
it("refuses a second live invitation and allows one after a revocation", async () => {
const { delegate, id } = await pendingGrant();
const second = await invite(delegate.username);
expect(second.status).toBe(409);
expect((await second.json()).meta.errorCode).toBe(
"sharing.invite.duplicate",
);
await revoke(id);
const third = await invite(delegate.username);
expect(third.status).toBe(201);
const newId = (await third.json()).data.id;
expect(newId).not.toBe(id);
// The history piles up rather than being reused: the first row is still
// there, still revoked, with its own dates.
const rows = await getPrismaClient().accountGrant.findMany({
orderBy: { createdAt: "asc" },
});
expect(rows).toHaveLength(2);
expect(rows[0].id).toBe(id);
expect(rows[0].revokedAt).not.toBeNull();
expect(rows[1].revokedAt).toBeNull();
});
it("refuses an account sharing with itself", async () => {
const owner = await makeUser("owner");
await signIn(owner.id);
const response = await invite(owner.username);
expect(response.status).toBe(422);
expect((await response.json()).meta.errorCode).toBe("sharing.invite.self");
expect(await getPrismaClient().accountGrant.count()).toBe(0);
});
it("says so when the identifier names nobody on this instance", async () => {
const owner = await makeUser("owner");
await signIn(owner.id);
const response = await invite("nobody-here@example.test");
expect(response.status).toBe(404);
expect(await getPrismaClient().accountGrant.count()).toBe(0);
});
});
describe("grant management cannot be reached from inside a switch", () => {
/**
* The property the design states as "a delegate must never grant, widen, or
* transfer". Not enforced by a re-delegation check in the handler — by the
* mode: bare `requireAuth()` refuses outright while a switch is active, so
* there is no path through these routes that runs as somebody else.
*/
async function switchedDelegate() {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
const third = await makeUser("third");
await signIn(owner.id);
const id = (await (await invite(delegate.username)).json()).data.id;
await signIn(delegate.id);
await accept(id);
await switchTo(owner.id);
return { owner, delegate, third, id };
}
it("refuses an invitation issued while acting as somebody else", async () => {
const { third } = await switchedDelegate();
const response = await invite(third.username);
expect(response.status).toBe(403);
expect((await response.json()).meta.errorCode).toBe(
"sharing.not_permitted",
);
// One grant in the table: the delegate's own. Nothing was widened.
expect(await getPrismaClient().accountGrant.count()).toBe(1);
});
it("refuses the grant list and the revoke while acting as somebody else", async () => {
const { id } = await switchedDelegate();
expect((await listGrants()).status).toBe(403);
expect((await revoke(id)).status).toBe(403);
const row = await getPrismaClient().accountGrant.findUniqueOrThrow({
where: { id },
});
expect(row.revokedAt).toBeNull();
});
it("still lets the switched session switch back", async () => {
const { delegate } = await switchedDelegate();
const session = await getPrismaClient().session.findFirstOrThrow({
where: { userId: delegate.id, actingAsUserId: { not: null } },
});
const back = await switchTo(null);
expect(back.status).toBe(200);
expect((await back.json()).data.actingAs).toBeNull();
expect(await actingAsOf(session.id)).toBeNull();
});
});
describe("the switch endpoint", () => {
it("refuses a switch into a grant that is not active", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await signIn(owner.id);
const lapsing = (
await (
await invite(
delegate.username,
new Date(Date.now() + 300).toISOString(),
)
).json()
).data.id;
const session = await signIn(delegate.id);
await accept(lapsing);
await new Promise((resolve) => setTimeout(resolve, 400));
const response = await switchTo(owner.id);
expect(response.status).toBe(403);
expect((await response.json()).meta.errorCode).toBe(
"sharing.access.denied",
);
expect(await actingAsOf(session.id)).toBeNull();
});
it("answers a nonexistent account and an ungranting one with the same bytes", async () => {
const stranger = await makeUser("stranger");
const caller = await makeUser("caller");
await signIn(caller.id);
const unknown = await switchTo("does-not-exist-at-all");
const ungranting = await switchTo(stranger.id);
expect(unknown.status).toBe(ungranting.status);
expect(await unknown.text()).toBe(await ungranting.text());
});
it("refuses over Bearer, where the carrier is the header", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await signIn(owner.id);
const id = (await (await invite(delegate.username)).json()).data.id;
const session = await signIn(delegate.id);
await accept(id);
cookieJar.clear();
headerJar.set("authorization", `Bearer ${await mintToken(delegate.id)}`);
const response = await switchTo(owner.id);
expect(response.status).toBe(400);
expect((await response.json()).meta.errorCode).toBe(
"sharing.switch.wrong_transport",
);
// Nothing was stamped on the browser session that happens to exist.
expect(await actingAsOf(session.id)).toBeNull();
});
});
describe("the grant list", () => {
it("shows both directions with the state the resolver would agree with", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
const other = await makeUser("other");
await signIn(owner.id);
const given = (await (await invite(delegate.username)).json()).data.id;
await signIn(other.id);
const received = (await (await invite(owner.username)).json()).data.id;
await signIn(owner.id);
const body = (await (await listGrants()).json()).data;
expect(body.given.map((g: { id: string }) => g.id)).toEqual([given]);
expect(body.given[0].account.id).toBe(delegate.id);
expect(body.given[0].state).toBe("PENDING");
expect(body.received.map((g: { id: string }) => g.id)).toEqual([received]);
expect(body.received[0].account.id).toBe(other.id);
// No e-mail addresses in either direction: a grant is not a way to collect
// the other party's contact details.
expect(JSON.stringify(body)).not.toContain("@example.test");
});
it("keeps ended grants in the list, marked as ended", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await signIn(owner.id);
const id = (await (await invite(delegate.username)).json()).data.id;
await revoke(id);
const body = (await (await listGrants()).json()).data;
expect(body.given).toHaveLength(1);
expect(body.given[0].state).toBe("REVOKED");
expect(body.given[0].revokedBy).toBe("GRANTOR");
});
});