From 208ddfb8ca02816de8cbdaffb2d3bf85ac8e72f0 Mon Sep 17 00:00:00 2001 From: adilallo <39313955+adilallo@users.noreply.github.com> Date: Wed, 22 Apr 2026 22:24:59 -0600 Subject: [PATCH] Custom session lifecycle --- docs/guides/backend-linear-tickets.md | 12 +- docs/guides/backend-roadmap.md | 3 +- lib/server/session.ts | 52 ++++++++ scripts/prune-sessions.ts | 47 ++++++++ tests/unit/sessionLifecycle.test.ts | 165 ++++++++++++++++++++++++++ 5 files changed, 272 insertions(+), 7 deletions(-) create mode 100644 scripts/prune-sessions.ts create mode 100644 tests/unit/sessionLifecycle.test.ts diff --git a/docs/guides/backend-linear-tickets.md b/docs/guides/backend-linear-tickets.md index 4af47bc..3163070 100644 --- a/docs/guides/backend-linear-tickets.md +++ b/docs/guides/backend-linear-tickets.md @@ -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-72–CR-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 **10–11** 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 7–8**; **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 13–14** 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 **10–11** 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 7–8**; **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 13–14** 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-72–CR-79**, **CR-83**, **CR-88**, **CR-89** are **Done**; **CR-77** (publish) **Done**; **CR-80–CR-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-72–CR-79**, **CR-83**, **CR-84**, **CR-85**, **CR-88**, **CR-89** are **Done**; **CR-77** (publish) **Done**; **CR-80–CR-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 **10–11** 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** | diff --git a/docs/guides/backend-roadmap.md b/docs/guides/backend-roadmap.md index b735d88..3935349 100644 --- a/docs/guides/backend-roadmap.md +++ b/docs/guides/backend-roadmap.md @@ -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 7–8 (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. --- diff --git a/lib/server/session.ts b/lib/server/session.ts index a62bc51..9bc73cb 100644 --- a/lib/server/session.ts +++ b/lib/server/session.ts @@ -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` §4–5). + * + * 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 { const token = (await cookies()).get(SESSION_COOKIE_NAME)?.value; @@ -31,6 +56,24 @@ export async function getSessionUser(): Promise { 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 { + 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 }; } diff --git a/scripts/prune-sessions.ts b/scripts/prune-sessions.ts new file mode 100644 index 0000000..517c933 --- /dev/null +++ b/scripts/prune-sessions.ts @@ -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 # 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()); diff --git a/tests/unit/sessionLifecycle.test.ts b/tests/unit/sessionLifecycle.test.ts new file mode 100644 index 0000000..0431072 --- /dev/null +++ b/tests/unit/sessionLifecycle.test.ts @@ -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); + } + }); +});