Custom session lifecycle

This commit is contained in:
adilallo
2026-04-22 22:24:59 -06:00
parent 5457d3554b
commit 208ddfb8ca
5 changed files with 272 additions and 7 deletions
+6 -6
View File
@@ -6,12 +6,12 @@ Copy each block into Linear (or your tracker) as a separate issue, **in order**.
### Review sync (relevant feedback only)
A backend review was merged into **[docs/backend-roadmap.md](backend-roadmap.md)** after checking the repo. **Incorporated:** custom session lifecycle follow-ups (not a mandate to adopt Auth.js/Lucia), **passwordless email (magic-link request)** rate limits in-memory until multi-instance + shared store, `RuleDraft` already has `updatedAt` (no migration to add it), **prefer external web vitals** over product Postgres by default, API error shape + request-id observability targets, **authorization v1** aligned with `app/api/rules`, Prisma **never edit applied migrations**, **profile / my rules / account** scope from Figma profile (`22143:900069`) as **Ticket 15** (change email deferred). **Excluded:** requiring NextAuth/Lucia; “add `updatedAt` on drafts”; hard ban on DB for vitals (softened to default external). **Parallel Linear issues:** **CR-84** (API errors — **unblocked** now that **CR-73** is Done), **CR-85** (session lifecycle — **unblocked** now that **CR-75** is Done)—see **Linear** table at the end of this doc.
A backend review was merged into **[docs/backend-roadmap.md](backend-roadmap.md)** after checking the repo. **Incorporated:** custom session lifecycle follow-ups (not a mandate to adopt Auth.js/Lucia), **passwordless email (magic-link request)** rate limits in-memory until multi-instance + shared store, `RuleDraft` already has `updatedAt` (no migration to add it), **prefer external web vitals** over product Postgres by default, API error shape + request-id observability targets, **authorization v1** aligned with `app/api/rules`, Prisma **never edit applied migrations**, **profile / my rules / account** scope from Figma profile (`22143:900069`) as **Ticket 15** (change email deferred). **Excluded:** requiring NextAuth/Lucia; “add `updatedAt` on drafts”; hard ban on DB for vitals (softened to default external). **Parallel Linear issues:** **CR-84** (API errors — **Done**), **CR-85** (session lifecycle — **Done**)—see **Linear** table at the end of this doc.
### Audit note (Linear CR-72+ vs repo, 2026-04)
- **Done in Linear and shipped:** **CR-72CR-76**, **CR-77** (publish from create flow), **CR-78** (template seed), **CR-79**, **CR-88**, **CR-89**. The **CR-72 → CR-83** numbering is the original **sequential plan**, not current blocking order; the **core product vertical** through publish + templates is effectively complete in-repo.
- **Backlog (still open):** **CR-80** (web vitals — file-based route remains), **CR-81** (public rule detail — no `GET /api/rules/[id]` or marketing detail page yet), **CR-82** (CI migrate smoke), **CR-85** (parallel hygiene — session lifecycle), **CR-86** (profile + account + draft resume — UI mostly placeholder), **CR-90** / **CR-91**, **CR-93** (template grid facets on marketing). **CR-84 Done** — canonical error contract `{ error: { code, message }, details? }` and `x-request-id` propagation shipped via `lib/server/{responses,requestId,apiRoute}.ts`; auth + drafts + rules routes migrated, remaining `app/api/*` are a follow-up pass.
- **Backlog (still open):** **CR-80** (web vitals — file-based route remains), **CR-81** (public rule detail — no `GET /api/rules/[id]` or marketing detail page yet), **CR-82** (CI migrate smoke), **CR-86** (profile + account + draft resume — UI mostly placeholder), **CR-90** / **CR-91**, **CR-93** (template grid facets on marketing). **CR-84 Done** — canonical error contract `{ error: { code, message }, details? }` and `x-request-id` propagation shipped via `lib/server/{responses,requestId,apiRoute}.ts`; auth + drafts + rules routes migrated, remaining `app/api/*` are a follow-up pass. **CR-85 Done** — multi-device session policy + lazy expired-row cleanup (per-user prune on every sign-in plus ~5% global sweep, no cron); ADR comment block in [`lib/server/session.ts`](../../lib/server/session.ts).
- **CR-83 Done (admin handoff scope):** [`docs/guides/ops-backend-deploy.md`](ops-backend-deploy.md) shipped as the **admin handoff sheet** (access, env vars, platform settings, open decisions). The full deploy runbook is intentionally split out — see the new follow-up tickets in [Ticket 12 / CR-83 follow-ups](#follow-up-tickets-filed-under-cr-83) below.
- **CR-86** is **no longer blocked** by publish — **CR-77** is **Done**; profile work is gated by **implementation**, not waiting on publish wiring.
- **Not in this ticket list** but called out in **[docs/backend-roadmap.md](backend-roadmap.md):** shared **rate-limit store** (e.g. Redis) before multi-instance; **`GET /api/create-flow/methods`** exists for facet scoring (Ticket 16 / CR-88) but is not duplicated as a separate doc ticket.
@@ -638,7 +638,7 @@ All six are titled `[Backend] …`, assigned to Vinod, in the **community-rule**
**Files:** [lib/server/session.ts](lib/server/session.ts), [app/api/auth/magic-link/verify/route.ts](app/api/auth/magic-link/verify/route.ts), optional `prisma` migration if new columns (unlikely).
**Linear:** [CR-85](https://linear.app/community-rule/issue/CR-85/backend-custom-session-lifecycle-cleanup-invalidation-policy) (**unblocked** — **CR-75** Done).
**Linear:** [CR-85](https://linear.app/community-rule/issue/CR-85/backend-custom-session-lifecycle-cleanup-invalidation-policy) **Done** — multi-device policy; lazy expired-row cleanup on every sign-in (per-user prune via `@@index([userId])` + ~5% global sweep); no rotation in v1; cleanup failures logged but never fail sign-in. ADR comment block lives at the top of [lib/server/session.ts](../../lib/server/session.ts); no Prisma migration needed.
---
@@ -702,13 +702,13 @@ All six are titled `[Backend] …`, assigned to Vinod, in the **community-rule**
**Follow-up (no doc ticket #):** **[CR-93](https://linear.app/community-rule/issue/CR-93/product-rank-template-cards-by-community-facets-reuse-get-apitemplates)** — marketing template grids ranked by user facets (API-ready; tests deferred with that issue).
Tickets **1011** can be deferred without blocking the core “auth + drafts + publish + templates” vertical slice. **Ticket 6 / CR-77** (publish) is **Done**. **Ticket 16** / **CR-88** (facet data + APIs + wizard method ranking) shipped **after 78**; **CR-93** tracks **marketing** template grids ranked by user facets (API-ready). **Ticket 17** / **CR-89** (**[Done](https://linear.app/community-rule/issue/CR-89/product-canon-custom-create-rule-wizard-routes-resume-progress-repo)**) canonizes the **custom** wizard in [`docs/create-flow.md`](create-flow.md) (progress bar, `[screenId]` routing). **Draft resume / hydration** follow-ups: **CR-86**. **Tickets 1314** are parallel (**CR-84** / **CR-85****Backlog**). **Ticket 15 / CR-86** is **parallel** (publish prerequisite met); implementation backlog. **Ticket 18** (**[CR-90](https://linear.app/community-rule/issue/CR-90/productbackend-invite-stakeholders-email-from-confirm-stakeholders)**) adds real **email-based stakeholder invites** to the `confirm-stakeholders` step — currently ships as a label-only chip list despite copy promising invites; **parallel** to the main chain, awaits design + product brief before implementation. **Ticket 19** (**[CR-91](https://linear.app/community-rule/issue/CR-91/productdesign-add-button-behavior-on-custom-rule-pages-and-final)**) is a **product/design** clarification ticket: the `Add` affordance is inconsistent across custom-rule pages (full custom-chip flow only on `core-values`; an `add` link that just expands the card stack on the four card-style pages) and the Final Review screen renders a `+` button per category that today is a no-op; needs a brief + Figma before any implementation lands.
Tickets **1011** can be deferred without blocking the core “auth + drafts + publish + templates” vertical slice. **Ticket 6 / CR-77** (publish) is **Done**. **Ticket 16** / **CR-88** (facet data + APIs + wizard method ranking) shipped **after 78**; **CR-93** tracks **marketing** template grids ranked by user facets (API-ready). **Ticket 17** / **CR-89** (**[Done](https://linear.app/community-rule/issue/CR-89/product-canon-custom-create-rule-wizard-routes-resume-progress-repo)**) canonizes the **custom** wizard in [`docs/create-flow.md`](create-flow.md) (progress bar, `[screenId]` routing). **Draft resume / hydration** follow-ups: **CR-86**. **Tickets 1314** are parallel (**CR-84** / **CR-85**both **Done**). **Ticket 15 / CR-86** is **parallel** (publish prerequisite met); implementation backlog. **Ticket 18** (**[CR-90](https://linear.app/community-rule/issue/CR-90/productbackend-invite-stakeholders-email-from-confirm-stakeholders)**) adds real **email-based stakeholder invites** to the `confirm-stakeholders` step — currently ships as a label-only chip list despite copy promising invites; **parallel** to the main chain, awaits design + product brief before implementation. **Ticket 19** (**[CR-91](https://linear.app/community-rule/issue/CR-91/productdesign-add-button-behavior-on-custom-rule-pages-and-final)**) is a **product/design** clarification ticket: the `Add` affordance is inconsistent across custom-rule pages (full custom-chip flow only on `core-values`; an `add` link that just expands the card stack on the four card-style pages) and the Final Review screen renders a `+` button per category that today is a no-op; needs a brief + Figma before any implementation lands.
---
## Linear (Community-rule team)
**Main chain (historical):** **CR-72 → CR-83** was the original **strict sequence**; **repo + Linear status today:** **CR-72CR-79**, **CR-83**, **CR-88**, **CR-89** are **Done**; **CR-77** (publish) **Done**; **CR-80CR-82** remain **Backlog** (web vitals, public rule detail, CI migrate smoke). **CR-83** (admin handoff) shipped as a narrow handoff sheet; the actual Cloudron deployment pipeline is split into the **`[Backend]` follow-up tickets** filed under it (env-var bridging → image registry → staging → production cutover → operator runbook → legacy decommission). **Parallel:** **CR-84**, **CR-85** (**Backlog**); **CR-86** / Ticket 15 (**Backlog** — publish **not** a blocker); **CR-93** (**Backlog**); **CR-90** / Ticket 18 (stakeholder invites); **CR-91** / Ticket 19 (`Add` button behavior).
**Main chain (historical):** **CR-72 → CR-83** was the original **strict sequence**; **repo + Linear status today:** **CR-72CR-79**, **CR-83**, **CR-84**, **CR-85**, **CR-88**, **CR-89** are **Done**; **CR-77** (publish) **Done**; **CR-80CR-82** remain **Backlog** (web vitals, public rule detail, CI migrate smoke). **CR-83** (admin handoff) shipped as a narrow handoff sheet; the actual Cloudron deployment pipeline is split into the **`[Backend]` follow-up tickets** filed under it (env-var bridging → image registry → staging → production cutover → operator runbook → legacy decommission). **Parallel (still open):** **CR-86** / Ticket 15 (**Backlog** — publish **not** a blocker); **CR-93** (**Backlog**); **CR-90** / Ticket 18 (stakeholder invites); **CR-91** / Ticket 19 (`Add` button behavior).
| Doc ticket | Linear | Title (short) |
| ---------: | --------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- |
@@ -731,7 +731,7 @@ Tickets **1011** can be deferred without blocking the core “auth + drafts +
| 12.5 | [CR-100](https://linear.app/community-rule/issue/CR-100/backend-steady-state-operator-runbook) | `[Backend] Steady-state operator runbook` |
| 12.6 | [CR-101](https://linear.app/community-rule/issue/CR-101/backend-decommission-legacy-expressmysql-backend) | `[Backend] Decommission legacy Express/MySQL backend` |
| 13 | [CR-84](https://linear.app/community-rule/issue/CR-84/backend-api-error-contract-request-id-logging) | API errors + request-id logging |
| 14 | [CR-85](https://linear.app/community-rule/issue/CR-85/backend-custom-session-lifecycle-cleanup-invalidation-policy) | Session lifecycle + cleanup |
| 14 | [CR-85](https://linear.app/community-rule/issue/CR-85/backend-custom-session-lifecycle-cleanup-invalidation-policy) | Session lifecycle + cleanup **Done** |
| 15 | [CR-86](https://linear.app/community-rule/issue/CR-86/backend-profile-dashboard-account-figma-profile) | Profile + account (Figma 22143:900069) |
| 16 | [CR-88](https://linear.app/community-rule/issue/CR-88/backend-template-recommendation-matrix-facet-data-seed-and-apis-no) | Template matrix (facets; no xlsx) |
| 17 | [CR-89](https://linear.app/community-rule/issue/CR-89/product-canon-custom-create-rule-wizard-routes-resume-progress-repo) | Canon create-flow (custom wizard + docs) **Done** |
+2 -1
View File
@@ -84,7 +84,7 @@ Plain-English entities (names can evolve):
**RuleTemplate — recommendation matrix (after v1 list):** Product may author templates in **spreadsheets** (e.g. one row per governance pattern, columns for **matching dimensions** such as group size, organization type, location, maturity, plus long-form fields for create-flow prefill). That implies: **normalized schema or versioned JSON** for dimensions × template fit (✓/✗, weights, or scores), an **import path** (export `.xlsx` / Sheets → validate → DB or build-time artifact), and **`GET /api/templates` (or a sibling route)** that accepts **user- or wizard-selected facets** and returns a **ranked or filtered** set. **Out of scope for first ship** of Tickets 78 (seed + display list); tracked as **Ticket 16** in [docs/backend-linear-tickets.md](backend-linear-tickets.md) and Linear **[CR-88](https://linear.app/community-rule/issue/CR-88/backend-template-recommendation-matrix-facet-data-seed-and-apis-no)** (**Done** — committed JSON + seed; no runtime `.xlsx`). Prefer **batch import** over live Google Sheets API in production unless ops explicitly wants sync.
**Session follow-ups to implement or decide:** token **rotation** on sensitive events, whether **new login invalidates other sessions**, and **cleanup** of expired `Session` rows (job or lazy delete). Revisit a small auth library (e.g. Auth.js, Lucia) only if maintaining custom code becomes costly.
**Session lifecycle (shipped, [CR-85](https://linear.app/community-rule/issue/CR-85/backend-custom-session-lifecycle-cleanup-invalidation-policy)):** **Multi-device** policy — a new sign-in does **not** invalidate the user's other valid sessions. **Cleanup is lazy and cron-free:** every `createSessionForUser` prunes that user's expired rows (uses `@@index([userId])`); ~5% of sign-ins also run a global sweep so rows from users who never return remain bounded over months. Cleanup failures are logged but never fail the sign-in. **Rotation** on privilege-sensitive actions is deferred to v1.1. See the ADR comment block at the top of [`lib/server/session.ts`](../../lib/server/session.ts). Revisit a small auth library (e.g. Auth.js, Lucia) only if maintaining custom code becomes costly.
**RuleDraft future (not v1):** versioning, multiple drafts per user, easier corruption recovery—only if product needs them.
@@ -96,6 +96,7 @@ Align JSON shapes with `app/(app)/create/types.ts` as it matures.
- **Decision:** **Custom** database-backed sessions + **email magic link**; cookies are **httpOnly**; session and magic-link tokens are hashed at rest.
- **Rate limiting (magic-link request):** **In-memory** is acceptable for a **single Node process**. It does **not** coordinate across instances—**add a shared limiter (e.g. Redis)** before horizontal scaling or serious abuse exposure.
- **Lifecycle policy (shipped, [CR-85](https://linear.app/community-rule/issue/CR-85/backend-custom-session-lifecycle-cleanup-invalidation-policy)):** multi-device (sign-in does not revoke other valid sessions); lazy expired-row cleanup on every sign-in (per-user prune + ~5% global sweep) — no cron required. Token rotation deferred to v1.1. Canonical comment block lives at the top of [`lib/server/session.ts`](../../lib/server/session.ts).
- Do **not** treat “switch to NextAuth/Lucia” as required for v1; document the custom lifecycle above instead.
---
+52
View File
@@ -1,11 +1,36 @@
import { cookies } from "next/headers";
import type { User } from "@prisma/client";
import { logger } from "../logger";
import { prisma } from "./db";
import { getSessionPepper } from "./env";
import { hashSessionToken, newSessionToken } from "./hash";
/**
* Custom session lifecycle (CR-85).
*
* Decisions documented here so the implementation below is the canonical
* source of truth (referenced from `docs/guides/backend-roadmap.md` §45).
*
* 1. **Policy: multi-device.** A new sign-in (`createSessionForUser`) does
* NOT delete the user's other still-valid sessions. Users routinely use
* phone + laptop and there is no v1 security argument for forcing a
* single active session — pre-publish state lives in `localStorage`
* until "Save & Exit", and `/api/auth/logout` only revokes the current
* cookie by design.
* 2. **Rotation: deferred.** No token rotation on privilege-sensitive
* actions in v1. Revisit if/when product requires it (ticket calls
* this v1.1).
* 3. **Cleanup: lazy, two-tier, no cron.** Every sign-in prunes the
* signing user's own expired rows (cheap — uses `@@index([userId])`).
* A small fraction of sign-ins (`SESSION_GLOBAL_PRUNE_PROB`) also runs
* a global sweep so rows from users who never return are still bounded
* over months. Cleanup is best-effort: a prune failure never fails the
* sign-in itself.
*/
export const SESSION_COOKIE_NAME = "cr_session";
const SESSION_MAX_AGE_SEC = 60 * 60 * 24 * 30;
const SESSION_GLOBAL_PRUNE_PROB = 0.05;
export async function getSessionUser(): Promise<User | null> {
const token = (await cookies()).get(SESSION_COOKIE_NAME)?.value;
@@ -31,6 +56,24 @@ export async function getSessionUser(): Promise<User | null> {
return session.user;
}
/**
* Delete expired `Session` rows. Scoped to a single user when `userId` is
* provided (uses the `@@index([userId])` lookup); otherwise sweeps the
* whole table. Returns the number of rows deleted.
*/
export async function pruneExpiredSessions(
opts: { userId?: string } = {},
): Promise<number> {
const where: { expiresAt: { lt: Date }; userId?: string } = {
expiresAt: { lt: new Date() },
};
if (opts.userId) {
where.userId = opts.userId;
}
const { count } = await prisma.session.deleteMany({ where });
return count;
}
export async function createSessionForUser(
userId: string,
): Promise<{ token: string; expiresAt: Date }> {
@@ -47,6 +90,15 @@ export async function createSessionForUser(
},
});
try {
await pruneExpiredSessions({ userId });
if (Math.random() < SESSION_GLOBAL_PRUNE_PROB) {
await pruneExpiredSessions();
}
} catch (err) {
logger.warn("[session] expired-row cleanup failed", err);
}
return { token, expiresAt };
}
+47
View File
@@ -0,0 +1,47 @@
/**
* Manual prune-expired-sessions script for CR-85 verification.
*
* Usage (from repo root):
* node --env-file=.env --import tsx scripts/prune-sessions.ts # global sweep
* node --env-file=.env --import tsx scripts/prune-sessions.ts <userId> # per-user
*
* Intentionally does NOT import `lib/server/session` — that module pulls in
* `next/headers` which requires a Next request context. For ad-hoc DB
* surgery we talk to Prisma directly.
*/
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
async function main() {
const userId = process.argv[2];
const now = new Date();
const before = await prisma.session.count();
const { count } = await prisma.session.deleteMany({
where: {
expiresAt: { lt: now },
...(userId ? { userId } : {}),
},
});
const after = await prisma.session.count();
console.log(
JSON.stringify(
{
scope: userId ?? "global",
now: now.toISOString(),
sessionsBefore: before,
pruned: count,
sessionsAfter: after,
},
null,
2,
),
);
}
main()
.catch((err) => {
console.error(err);
process.exitCode = 1;
})
.finally(() => prisma.$disconnect());
+165
View File
@@ -0,0 +1,165 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const sessionCreateMock = vi.fn();
const sessionDeleteManyMock = vi.fn();
const getSessionPepperMock = vi.fn();
const newSessionTokenMock = vi.fn();
const hashSessionTokenMock = vi.fn();
const loggerWarnMock = vi.fn();
vi.mock("../../lib/server/db", () => ({
prisma: {
session: {
create: (...args: unknown[]) => sessionCreateMock(...args),
deleteMany: (...args: unknown[]) => sessionDeleteManyMock(...args),
},
},
}));
vi.mock("../../lib/server/env", () => ({
getSessionPepper: () => getSessionPepperMock(),
}));
vi.mock("../../lib/server/hash", () => ({
hashSessionToken: (...args: unknown[]) => hashSessionTokenMock(...args),
newSessionToken: () => newSessionTokenMock(),
}));
vi.mock("../../lib/logger", () => ({
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: (...args: unknown[]) => loggerWarnMock(...args),
error: vi.fn(),
},
}));
vi.mock("next/headers", () => ({
cookies: vi.fn(),
}));
import {
createSessionForUser,
pruneExpiredSessions,
} from "../../lib/server/session";
beforeEach(() => {
sessionCreateMock.mockReset();
sessionDeleteManyMock.mockReset();
getSessionPepperMock.mockReset();
newSessionTokenMock.mockReset();
hashSessionTokenMock.mockReset();
loggerWarnMock.mockReset();
getSessionPepperMock.mockReturnValue("test-pepper");
newSessionTokenMock.mockReturnValue("token-raw");
hashSessionTokenMock.mockReturnValue("token-hash");
sessionCreateMock.mockResolvedValue({});
sessionDeleteManyMock.mockResolvedValue({ count: 0 });
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("pruneExpiredSessions", () => {
it("deletes globally expired rows when no userId is supplied", async () => {
sessionDeleteManyMock.mockResolvedValueOnce({ count: 7 });
const count = await pruneExpiredSessions();
expect(count).toBe(7);
expect(sessionDeleteManyMock).toHaveBeenCalledTimes(1);
const arg = sessionDeleteManyMock.mock.calls[0]?.[0] as {
where: { expiresAt: { lt: Date }; userId?: string };
};
expect(arg.where.userId).toBeUndefined();
expect(arg.where.expiresAt.lt).toBeInstanceOf(Date);
});
it("scopes the prune to a single user when userId is supplied", async () => {
sessionDeleteManyMock.mockResolvedValueOnce({ count: 2 });
const count = await pruneExpiredSessions({ userId: "user-1" });
expect(count).toBe(2);
const arg = sessionDeleteManyMock.mock.calls[0]?.[0] as {
where: { expiresAt: { lt: Date }; userId?: string };
};
expect(arg.where.userId).toBe("user-1");
expect(arg.where.expiresAt.lt).toBeInstanceOf(Date);
});
it("never matches non-expired rows (multi-device safety)", async () => {
await pruneExpiredSessions({ userId: "user-1" });
const arg = sessionDeleteManyMock.mock.calls[0]?.[0] as {
where: { expiresAt: { lt: Date } };
};
expect(Object.keys(arg.where.expiresAt)).toEqual(["lt"]);
});
});
describe("createSessionForUser cleanup behaviour", () => {
it("creates the new session and prunes the same user's expired rows", async () => {
vi.spyOn(Math, "random").mockReturnValue(0.99);
const result = await createSessionForUser("user-1");
expect(result.token).toBe("token-raw");
expect(result.expiresAt).toBeInstanceOf(Date);
expect(sessionCreateMock).toHaveBeenCalledTimes(1);
expect(sessionDeleteManyMock).toHaveBeenCalledTimes(1);
const arg = sessionDeleteManyMock.mock.calls[0]?.[0] as {
where: { userId?: string; expiresAt: { lt: Date } };
};
expect(arg.where.userId).toBe("user-1");
expect(arg.where.expiresAt.lt).toBeInstanceOf(Date);
});
it("runs an additional global sweep when the probability roll succeeds", async () => {
vi.spyOn(Math, "random").mockReturnValue(0.01);
await createSessionForUser("user-1");
expect(sessionDeleteManyMock).toHaveBeenCalledTimes(2);
const userScoped = sessionDeleteManyMock.mock.calls[0]?.[0] as {
where: { userId?: string };
};
const globalSweep = sessionDeleteManyMock.mock.calls[1]?.[0] as {
where: { userId?: string };
};
expect(userScoped.where.userId).toBe("user-1");
expect(globalSweep.where.userId).toBeUndefined();
});
it("skips the global sweep when the probability roll fails", async () => {
vi.spyOn(Math, "random").mockReturnValue(0.06);
await createSessionForUser("user-1");
expect(sessionDeleteManyMock).toHaveBeenCalledTimes(1);
});
it("does not throw out of sign-in when cleanup fails", async () => {
vi.spyOn(Math, "random").mockReturnValue(0.01);
sessionDeleteManyMock.mockRejectedValue(new Error("db down"));
const result = await createSessionForUser("user-1");
expect(result.token).toBe("token-raw");
expect(sessionCreateMock).toHaveBeenCalledTimes(1);
expect(loggerWarnMock).toHaveBeenCalledTimes(1);
});
it("never deletes the user's other still-valid sessions (multi-device policy)", async () => {
vi.spyOn(Math, "random").mockReturnValue(0.99);
await createSessionForUser("user-1");
for (const call of sessionDeleteManyMock.mock.calls) {
const arg = call[0] as { where: { expiresAt?: { lt: Date } } };
expect(arg.where.expiresAt?.lt).toBeInstanceOf(Date);
}
});
});