docs(api): publish the switch refusal on the wipe, the deletion and the encrypted export

The three routes that act on the whole of an account — `DELETE
/api/settings/data`, `DELETE /api/settings/account`, `POST
/api/export/encrypted` — name no record. They resolve the caller and act on
whatever the caller owns, so the question a switched session puts to them is
what happens when the browser believes it is somewhere else.

They already refuse: all three resolve through `requireFreshMfaIfEnrolled` or
its erasure sibling, and both open with `requireAuth()`, which reads the acting
carrier and throws `sharing.not_permitted` for anything but `none`. What was
missing is that the contract never said so. `/api/settings/{data,account}`
published a 403 about narrow Bearer scope alone, and the encrypted export
published no 403 at all, so a client that switches records had no way to learn
which of its calls stop working short of trying one.

One sentence, spliced onto the three, byte-identical for the same reason the
delegable refusal is one string. `switched-session-destructive-refusal.test.ts`
drives each route with a live whole-record MANAGE switch and asserts both
halves: the status, the code and the wording, and that neither record moved —
a refusal raised after the transaction would satisfy the first alone. The
export leg additionally asserts no archive rode along with the 403.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 10:49:12 +02:00
1 parent 927496236a
commit b6d3f9903f
5 files changed
+241 -5

No files matched your search

+25 -2
View File
@@ -1764,6 +1764,15 @@ paths:
type: string
format: binary
"401": *a1
"403":
description: "Refused: the request was made while the session is acting on another account (`meta.errorCode` =
`sharing.not_permitted`). This operation resolves the CALLER and never a named record, so under a switch it
refuses instead of quietly answering with the caller's own — leave the shared record first. No grant at any
level opens it."
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a4
"429": *a5
/api/auth/check-user:
@@ -12203,8 +12212,15 @@ paths:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"403":
description: The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal
description: >-
The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal
happens before any second-factor proof is looked at.
Refused: the request was made while the session is acting on another account (`meta.errorCode` =
`sharing.not_permitted`). This operation resolves the CALLER and never a named record, so under a switch it
refuses instead of quietly answering with the caller's own — leave the shared record first. No grant at any
level opens it.
content:
application/json:
schema:
@@ -12426,8 +12442,15 @@ paths:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"403":
description: The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal
description: >-
The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal
happens before any second-factor proof is looked at.
Refused: the request was made while the session is acting on another account (`meta.errorCode` =
`sharing.not_permitted`). This operation resolves the CALLER and never a named record, so under a switch it
refuses instead of quietly answering with the caller's own — leave the shared record first. No grant at any
level opens it.
content:
application/json:
schema:
+9 -1
View File
@@ -20,7 +20,11 @@
import { z } from "zod/v4";
import type { ZodOpenApiObject } from "zod-openapi";
import { backupPayloadSchema } from "@/lib/validations/backup";
import { errorEnvelope, stdResponses } from "./shared";
import {
errorEnvelope,
SHARING_NOT_PERMITTED_DESCRIPTION,
stdResponses,
} from "./shared";
const exportQuery = z
.object({
@@ -305,6 +309,10 @@ export const exportPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"403": {
description: SHARING_NOT_PERMITTED_DESCRIPTION,
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
+5 -2
View File
@@ -19,6 +19,7 @@ import {
dataEnvelope,
errorEnvelope,
recordRefusal,
SHARING_NOT_PERMITTED_DESCRIPTION,
stdResponses,
} from "./shared";
@@ -615,7 +616,8 @@ export const settingsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
"403": {
description:
"The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal happens before any second-factor proof is looked at.",
"The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal happens before any second-factor proof is looked at.\n\n" +
SHARING_NOT_PERMITTED_DESCRIPTION,
content: { "application/json": { schema: errorEnvelope } },
},
},
@@ -801,7 +803,8 @@ export const settingsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
"403": {
description:
"The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal happens before any second-factor proof is looked at.",
"The Bearer token carries a narrow scope. Erasure needs a cookie session or a full-access token; the refusal happens before any second-factor proof is looked at.\n\n" +
SHARING_NOT_PERMITTED_DESCRIPTION,
content: { "application/json": { schema: errorEnvelope } },
},
},
+19
View File
@@ -379,6 +379,25 @@ export function idempotentWrite(): IdempotentConflictResponse {
export const SHARING_ACCESS_DENIED_DESCRIPTION =
"Refused: this request named a record the caller may not act on (`meta.errorCode` = `sharing.access.denied`). Byte-identical for an account that does not exist, an account that granted nothing, a grant whose sections do not reach this surface, and a read grant on a request that writes — the response carries no reason, so it is not an account-enumeration oracle. The reason reaches the record owner's activity feed and the operator's audit trail instead. On the cookie transport the same code answers one further case: a session that has entered a shared record and sends a request without its record-context assertion, which is a client older than the fence — leave the record and reload. A request that names no record but the caller's own never reaches this response, so a client that never switches and never sends the per-request account selector will not see it.";
/**
* The OTHER sharing refusal, for a route that names no record at all.
*
* A route resolving through `requireAuth` serves the caller and only the
* caller, and refuses outright while the browser is inside somebody else's
* record rather than quietly answering with the caller's own rows. That
* posture matters most on the routes where being wrong is unrecoverable — the
* record wipe, the account deletion, the encrypted archive — and it was
* documented nowhere on them. A client that switches records has to know which
* of its calls stop working, and finding out by wiping the wrong record is not
* a contract.
*
* One sentence, spliced onto whatever else the operation's 403 already says,
* for the same reason the delegable refusal is one string: a paraphrase per
* path invites a client to tell them apart.
*/
export const SHARING_NOT_PERMITTED_DESCRIPTION =
"Refused: the request was made while the session is acting on another account (`meta.errorCode` = `sharing.not_permitted`). This operation resolves the CALLER and never a named record, so under a switch it refuses instead of quietly answering with the caller's own — leave the shared record first. No grant at any level opens it.";
/**
* The 403 above, optionally sharing the status with the reasons a route already
* refuses for.
@@ -0,0 +1,183 @@
/**
* The three destructive account routes, driven with a live record switch on.
*
* `DELETE /api/settings/data` wipes a record, `DELETE /api/settings/account`
* erases an account, and `POST /api/export/encrypted` puts a whole record into
* one file. None of them names a record: they resolve the CALLER and act on
* whatever that caller owns. So the question a switched session asks of them is
* not "may I do this to the record I am inside" — it is "what happens when the
* browser believes it is somewhere else and the route does not check".
*
* The answer must be a refusal, and the same one every undeclared route gives:
* 403, `sharing.not_permitted`, "cannot be used while acting on another
* account". Silently serving the delegate's own record is the failure this
* pins against, and it is the worst-shaped one in the product — a person who
* believes they are looking after somebody else's record presses "delete
* everything" and erases their own.
*
* Both halves are asserted: the status AND that nothing moved. A refusal that
* happens after the transaction would satisfy the first alone.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, truncateAllTables, switchSessionTo } 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),
}));
const OWNER_ID = "switch-destructive-owner";
const DELEGATE_ID = "switch-destructive-delegate";
/** The delegate's own reading. It must still be there afterwards. */
async function seedRecords() {
const prisma = getPrismaClient();
for (const [id, name] of [
[OWNER_ID, "owner"],
[DELEGATE_ID, "delegate"],
] as const) {
await prisma.user.create({
data: {
id,
username: `switch-destructive-${name}`,
email: `switch-destructive-${name}@example.test`,
timezone: "UTC",
},
});
}
await prisma.measurement.create({
data: {
userId: DELEGATE_ID,
type: "WEIGHT",
value: 72,
unit: "kg",
measuredAt: new Date(),
},
});
}
/**
* Sign the delegate in and switch into the owner's record at MANAGE — the
* widest grant the product mints, so nothing below can be explained away by
* the grant simply being too narrow.
*/
async function switchIntoOwnersRecord() {
const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants");
const invited = await inviteGrant({
grantorId: OWNER_ID,
granteeId: DELEGATE_ID,
access: "MANAGE",
scope: null,
});
await acceptGrant({ grantId: invited.id, granteeId: DELEGATE_ID });
const session = await getPrismaClient().session.create({
data: {
userId: DELEGATE_ID,
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
},
});
cookieJar.set("healthlog_session", session.id);
await switchSessionTo(session.id, OWNER_ID);
}
/** The one refusal all three must give. */
async function expectSwitchRefusal(response: Response) {
expect(response.status).toBe(403);
const body = (await response.json()) as {
error: string;
meta?: { errorCode?: string };
};
expect(body.meta?.errorCode).toBe("sharing.not_permitted");
expect(body.error).toBe(
"This endpoint cannot be used while acting on another account",
);
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
await seedRecords();
await switchIntoOwnersRecord();
});
describe("a switched session reaches none of the record-wide destructive routes", () => {
it("refuses the record wipe and wipes neither record", async () => {
const { DELETE } = await import("@/app/api/settings/data/route");
await expectSwitchRefusal(
await DELETE(
new Request("http://localhost/api/settings/data", {
method: "DELETE",
body: JSON.stringify({ confirm: "DELETE" }),
}) as never,
),
);
const prisma = getPrismaClient();
expect(
await prisma.measurement.count({ where: { userId: DELEGATE_ID } }),
"the delegate's own reading survived",
).toBe(1);
expect(
await prisma.user.count({
where: { id: { in: [OWNER_ID, DELEGATE_ID] } },
}),
).toBe(2);
});
it("refuses the account deletion and deletes neither account", async () => {
const { DELETE } = await import("@/app/api/settings/account/route");
await expectSwitchRefusal(
await DELETE(
new Request("http://localhost/api/settings/account", {
method: "DELETE",
body: JSON.stringify({ confirm: "DELETE_ACCOUNT" }),
}) as never,
),
);
expect(
await getPrismaClient().user.count({
where: { id: { in: [OWNER_ID, DELEGATE_ID] } },
}),
).toBe(2);
});
it("refuses the encrypted export and hands out no archive", async () => {
const { POST } = await import("@/app/api/export/encrypted/route");
const response = await POST(
new Request("http://localhost/api/export/encrypted", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ passphrase: "a-long-enough-passphrase-123" }),
}) as never,
);
await expectSwitchRefusal(response);
// Not an archive under any content type: a refusal that still streamed the
// bytes would read as a 403 to a status check and as an export to a user.
expect(response.headers.get("content-type")).not.toContain(
"application/octet-stream",
);
});
});