feat(02-12.1): stamp a record-context epoch on every session

- add sessions.record_epoch, owned by a BEFORE UPDATE trigger so the
  ON DELETE SET NULL path and raw SQL writes cannot skip it
- normalise pre-existing rows so epoch zero means never switched
- project the epoch off the row getSession already loads
This commit is contained in:
Marc-André Bombeck committed 2026-08-06 17:52:10 +02:00
1 parent a08e82d0be
commit 5c9befc0ae
7 files changed
+376 -2

No files matched your search

@@ -100,6 +100,7 @@ const SESSION_OK = {
id: "sess-1",
expiresAt: new Date(Date.now() + 3_600_000),
actingAsUserId: null,
recordEpoch: 0,
},
user: SESSION_USER as never,
};
@@ -81,6 +81,7 @@ function signedIn() {
id: "s1",
expiresAt: new Date(Date.now() + 1e6),
actingAsUserId: null,
recordEpoch: 0,
},
user: { id: "user-1" } as never,
});
@@ -148,16 +148,25 @@ function user(
};
}
/** Sign the delegate in over the cookie transport. */
/**
* Sign the delegate in over the cookie transport.
*
* `recordEpoch` defaults to 0, which is the record-session fence's exemption:
* a session that has never been pointed at another record is served without a
* fence header, so every pre-fence case in this file keeps meaning what it
* meant. Cases that want the fence live pass an epoch explicitly.
*/
function signedInCookie(
actingAsUserId: string | null = null,
role: "USER" | "ADMIN" = "USER",
recordEpoch = 0,
): void {
vi.mocked(getSession).mockResolvedValue({
session: {
id: "session-1",
expiresAt: new Date(Date.now() + 3_600_000),
actingAsUserId,
recordEpoch,
},
user: user(DELEGATE, role) as never,
});
@@ -649,6 +658,7 @@ describe("the cookie-only helpers stay out of reach of a switch", () => {
id: "session-1",
expiresAt: new Date(Date.now() + 3_600_000),
actingAsUserId: OWNER,
recordEpoch: 0,
},
user: {
...user(DELEGATE),
+15 -1
View File
@@ -246,7 +246,12 @@ export async function createSession(
}
export async function getSession(): Promise<{
session: { id: string; expiresAt: Date; actingAsUserId: string | null };
session: {
id: string;
expiresAt: Date;
actingAsUserId: string | null;
recordEpoch: number;
};
user: User;
} | null> {
try {
@@ -363,6 +368,15 @@ export async function getSession(): Promise<{
// `src/__tests__/acting-account-boundary-guard.test.ts` freezes the set
// of files allowed to touch this field.
actingAsUserId: session.actingAsUserId,
// v1.37.0 — the selector's move counter, projected off the same row for
// the same reason. It is what makes the fence's central property true:
// the epoch a request is validated against and the `actingAsUserId` the
// handler is about to serve under are one row read, so they cannot
// straddle a switch that landed between two queries.
//
// Written by a database trigger only (migration 0302); nothing in the
// application assigns it, and the `BEFORE` timing means nothing can.
recordEpoch: session.recordEpoch,
},
user: session.user,
};