mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
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:
1 parent
927496236a
commit
b6d3f9903f
5 files changed
+241
-5
No files matched your search
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 } },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user