mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
feat(02-12.1): refuse a record request whose context has moved
- both record resolvers check the fence after authentication and before the carrier, off the one session row that also decides the scope - POST /api/account/switch takes an optional expectedEpoch and writes conditionally, so concurrent switchers resolve to one outcome - GET /api/auth/me publishes the context a browser bootstraps from - every response that resolved a record scope echoes it back - regenerate the API contract
This commit is contained in:
1 parent
59359738c8
commit
45fec0fc1b
15 files changed
+1631
-98
No files matched your search
@@ -21,11 +21,37 @@
|
||||
* 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.
|
||||
*
|
||||
* v1.37.0 — unfenced, deliberately, and the reason is the same one that makes
|
||||
* it an actor surface: this is the way back. A session whose context cannot be
|
||||
* proved must still be able to leave the record it may or may not be in, and
|
||||
* fencing the exit would make an unprovable context unrecoverable.
|
||||
*
|
||||
* What it takes instead is an optional `expectedEpoch`, which turns the
|
||||
* selector write into a compare-and-set. Two tabs pressing the switcher at once
|
||||
* both name the epoch they saw; one `UPDATE` matches, the loser gets zero rows
|
||||
* and the same 409 the fence raises, and the browser reconciles through
|
||||
* `/api/auth/me` before retrying. That is the monotonic ordering a server-side
|
||||
* pending lease was proposed for, in one statement, with no intermediate state
|
||||
* for a crashed initiator to strand.
|
||||
*
|
||||
* An ABSENT `expectedEpoch` keeps the unconditional write, and the two halves
|
||||
* of what that means are worth stating together:
|
||||
*
|
||||
* * It is reachable only by a bundle that predates the fence. The switcher is
|
||||
* rendered from `/api/auth/me` data, so a fence-aware client has adopted a
|
||||
* context before it can offer a switch at all — it never omits the field.
|
||||
* * It can therefore clobber a concurrent fenced switch. That is accepted:
|
||||
* the fenced tab's next request asserts an epoch the row no longer holds,
|
||||
* it takes the 409, and it reconciles to the truth; the pre-fence tab
|
||||
* reloads into a fence-aware bundle immediately afterwards. Fail-closed and
|
||||
* self-healing, bounded to the deploy window.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import {
|
||||
apiHandler,
|
||||
RecordSessionChangedError,
|
||||
requireActorAuth,
|
||||
SharingAccessDeniedError,
|
||||
} from "@/lib/api-handler";
|
||||
@@ -39,7 +65,10 @@ import {
|
||||
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 {
|
||||
pointSessionAt,
|
||||
pointSessionAtIfUnchanged,
|
||||
} from "@/lib/sharing/acting-session";
|
||||
import { findActiveGrant } from "@/lib/sharing/grants";
|
||||
import { switchAccountSchema } from "@/lib/validations/account-sharing";
|
||||
|
||||
@@ -66,18 +95,56 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
}
|
||||
|
||||
const { accountId } = parsed.data;
|
||||
const { accountId, expectedEpoch } = parsed.data;
|
||||
const ip = getClientIp(request);
|
||||
|
||||
/**
|
||||
* Move the selector, conditionally when the caller named an epoch.
|
||||
*
|
||||
* Returns the record context the session ends up in, so the response can
|
||||
* publish it — this endpoint and `/api/auth/me` are the only two responses a
|
||||
* client may ADOPT a context from, and both carry it in the body. Every other
|
||||
* response's echo is used to validate and never to adopt.
|
||||
*
|
||||
* `null` on the unconditional arm, and that is the honest answer rather than
|
||||
* a gap: the epoch after an unconditional write is whatever the trigger
|
||||
* decided, this statement did not read it back, and a number computed here
|
||||
* would be wrong the moment the target equalled the current selector (the
|
||||
* trigger's `IS DISTINCT FROM` clause declines to move on a no-op write). A
|
||||
* caller that omitted `expectedEpoch` is by construction a bundle that would
|
||||
* not read the field anyway, and `/api/auth/me` states the truth on its next
|
||||
* boot.
|
||||
*/
|
||||
async function moveSelector(
|
||||
target: string | null,
|
||||
): Promise<{ epoch: number; scope: string | null } | null | "stale"> {
|
||||
if (expectedEpoch === undefined) {
|
||||
await pointSessionAt(auth.session.id, auth.user.id, target);
|
||||
return null;
|
||||
}
|
||||
const outcome = await pointSessionAtIfUnchanged(
|
||||
auth.session.id,
|
||||
auth.user.id,
|
||||
target,
|
||||
expectedEpoch,
|
||||
);
|
||||
if (outcome.kind === "stale") return "stale";
|
||||
return { epoch: outcome.epoch, scope: target };
|
||||
}
|
||||
|
||||
if (accountId === null) {
|
||||
await pointSessionAt(auth.session.id, auth.user.id, null);
|
||||
const moved = await moveSelector(null);
|
||||
if (moved === "stale") {
|
||||
annotate({ meta: { sharing_refusal: "switch_epoch_stale" } });
|
||||
throw new RecordSessionChangedError();
|
||||
}
|
||||
await auditLog("sharing.switch.off", {
|
||||
userId: auth.user.id,
|
||||
details: {},
|
||||
ipAddress: ip,
|
||||
}).catch(() => {});
|
||||
annotate({ action: { name: "sharing.switch.off" } });
|
||||
return apiSuccess({ actingAs: null });
|
||||
return apiSuccess({ actingAs: null, recordSession: moved });
|
||||
}
|
||||
|
||||
// The same predicate the resolver uses, on the same table, with no second
|
||||
@@ -105,7 +172,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
select: { id: true, username: true, displayName: true },
|
||||
});
|
||||
|
||||
await pointSessionAt(auth.session.id, auth.user.id, owner.id);
|
||||
const moved = await moveSelector(owner.id);
|
||||
if (moved === "stale") {
|
||||
annotate({ meta: { sharing_refusal: "switch_epoch_stale" } });
|
||||
throw new RecordSessionChangedError();
|
||||
}
|
||||
|
||||
await auditLog("sharing.switch.on", {
|
||||
userId: auth.user.id,
|
||||
@@ -125,5 +196,6 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
displayName: owner.displayName,
|
||||
access: grant.access,
|
||||
},
|
||||
recordSession: moved,
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from "@/lib/modules/gate";
|
||||
import { parseTourProgress } from "@/lib/onboarding/tour-progress";
|
||||
import { resolveAccountAccess } from "@/lib/sharing/account-access";
|
||||
import { recordSessionForPayload } from "@/lib/sharing/record-session-fence";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -173,5 +174,23 @@ export const GET = apiHandler(async () => {
|
||||
// directly. Always present — an account with no grants gets an empty list,
|
||||
// not a missing field.
|
||||
accountAccess,
|
||||
// v1.37.0 — the record-session fence's bootstrap, and the only place a
|
||||
// browser can learn its context from scratch.
|
||||
//
|
||||
// `epoch` is the session row's selector counter; `scope` is the account the
|
||||
// session is pointed at, or null for its own record. The client adopts both
|
||||
// and attaches them to every subsequent same-origin request, so the server
|
||||
// can refuse a request formed under a context that has since moved. This
|
||||
// response and `POST /api/account/switch` are the only two a client may
|
||||
// ADOPT from — every other response carries the context in headers, and
|
||||
// those are used to validate a response, never to learn one.
|
||||
//
|
||||
// Null on the Bearer transport, which has no session row and no switch
|
||||
// state to be stale about. A native client neither sends nor receives the
|
||||
// fence headers; the frozen contract is untouched.
|
||||
// Derived by the fence rather than read off the context here: the columns
|
||||
// behind it have one reader by design, and a route that projected them
|
||||
// itself would be a second statement of what a record context is.
|
||||
recordSession: recordSessionForPayload(auth),
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user