// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0 // A revoked app session ends the service sessions the modules minted for it (joint contract ยง14): the // hook the web registers with kit-auth (`onSessionRevoked`) asks every installed module's account // share to revoke them server-side by the session's reference, bounded, and never fails a sign-out. // On the Postgres profile it is durable: the account module's revocation outbox records it, and the // worker's revocation job delivers what a platform that was down did not confirm, exactly once. import { createSessionStore, deriveKeys, kitSessionRef, sessionRefKey, sessions } from "@conusai-kit/auth" import { buildRegistry, Container, contribute, defineModule } from "@conusai-kit/core" import { createMemoryDataSource, DATA_SOURCE, entitiesOf, SYSTEM_DATA, toInstant } from "@conusai-kit/data" import { SUPPORT_PLATFORM } from "@conusai-kit/support" // kit:support import { recordingSupportPlatform } from "@conusai-kit/support/testing" // kit:support import { accountData, accountDataOf } from "@conusai-kit/tenancy" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { revokeDerivedSessions } from "../apps/web/src/server/derived-sessions" import { ACCOUNT_REGISTRY, accountSessionRevocations, recordSessionRevocation } from "../modules/account/index.js" import { appModules } from "../modules/index.js" const registry = buildRegistry([...appModules]) const ref = { provider: "oidc", opaqueId: "0199abcd-1234-7000-8000-00000000d001" } function ports(bind: (root: Container) => void, timeoutMs?: number, using = registry) { const root = new Container() bind(root) const warnings: string[] = [] return { ports: { registry: using, root, log: { warn: (message: string) => warnings.push(message) }, ...(timeoutMs ? { timeoutMs } : {}), }, warnings, } } describe("revokeDerivedSessions", () => { it("answers at once when no installed module mints derived sessions", async () => { const without = buildRegistry(appModules.filter((module) => module.id !== "support")) const none = ports(() => undefined, undefined, without) await expect(revokeDerivedSessions(none.ports, ref)).resolves.toBeUndefined() expect(none.warnings).toEqual([]) expect(sessionRefKey(ref)).toContain(ref.opaqueId) }) // kit:support:begin it("revokes the support widget sessions minted for exactly that app session", async () => { const support = recordingSupportPlatform() const { ports: bound, warnings } = ports((root) => root.value(SUPPORT_PLATFORM, support)) await revokeDerivedSessions(bound, ref) expect(support.calls).toEqual([ { method: "revokeWidgetSessions", subject: { subject: "", identitySession: sessionRefKey(ref) } }, ]) expect(warnings).toEqual([]) }) it("never fails the sign-out: an unreachable or slow module is logged by name and the hook answers", async () => { const support = recordingSupportPlatform() const client = support.client as { revokeWidgetSessions: unknown } client.revokeWidgetSessions = async () => { throw new TypeError("fetch failed") } const down = ports((root) => root.value(SUPPORT_PLATFORM, support)) await expect(revokeDerivedSessions(down.ports, ref)).resolves.toBeUndefined() expect(down.warnings).toEqual(["auth: support did not revoke the sessions of a revoked app session"]) client.revokeWidgetSessions = () => new Promise(() => undefined) const slow = ports((root) => root.value(SUPPORT_PLATFORM, support), 50) await expect(revokeDerivedSessions(slow.ports, ref)).resolves.toBeUndefined() expect(slow.warnings).toHaveLength(1) }) it("asks the modules directly when the revocation could not be recorded", async () => { const support = recordingSupportPlatform() const { ports: bound, warnings } = ports((root) => root.value(SUPPORT_PLATFORM, support)) await revokeDerivedSessions( { ...bound, durable: async () => { throw new Error("the database refused the write") }, }, ref, ) expect(support.calls).toEqual([ { method: "revokeWidgetSessions", subject: { subject: "", identitySession: sessionRefKey(ref) } }, ]) expect(warnings).toEqual([ "auth: the revocation of an app session could not be recorded; asking the modules directly", ]) }) it("does nothing on the fake platform, where no widget session was minted", async () => { const offline = ports((root) => root.value(SUPPORT_PLATFORM, { mode: "offline" })) await expect(revokeDerivedSessions(offline.ports, ref)).resolves.toBeUndefined() expect(offline.warnings).toEqual([]) }) // kit:support:end it("asks only the modules that mint derived sessions", () => { const revoking = accountDataOf(registry) .filter((entry) => entry.revokeSessions) .map((entry) => entry.key) const expected: string[] = [] expected.push("support") // kit:support expect(revoking).toEqual(expected) }) }) /** * The durable path, end to end on the memory data source: kit-auth's own session store signs a * device out and calls the web's hook, which records the revocation in the account module's outbox; * the worker's revocation job (the registry's own handler) delivers it. `minting` stands for any * module that mints derived sessions, and its platform can be taken down and brought back. */ describe("the revocation outbox", () => { const platform = { down: false, delivered: [] as string[], refused: 0 } const minting = defineModule({ id: "minting", version: "0.1.0", kit: "^0.1.0", requires: ["tenancy"], extends: [ contribute(accountData, [ { key: "minting", async revokeSessions({ session }) { if (platform.down) { platform.refused += 1 throw new TypeError("fetch failed") } if (session) platform.delivered.push(session) }, }, ]), ], }) const registry = buildRegistry([...appModules, minting]) const job = registry.jobs["account.sessions.revoke"] const START = Date.parse("2026-10-25T10:00:00.000Z") beforeEach(() => { vi.useFakeTimers({ toFake: ["Date"] }) vi.setSystemTime(START) platform.down = false platform.delivered.length = 0 platform.refused = 0 }) afterEach(() => { vi.useRealTimers() }) async function app() { const data = createMemoryDataSource({ entities: entitiesOf(registry, "postgres") }) const root = new Container() root.value(DATA_SOURCE, data) root.value(SYSTEM_DATA, data) root.value(ACCOUNT_REGISTRY, registry) root.value(SUPPORT_PLATFORM, { mode: "offline" }) // kit:support const warnings: string[] = [] const log = { warn: (message: string) => warnings.push(message) } const store = createSessionStore({ config: { idleSeconds: 3600, absoluteSeconds: 86400, cookieName: "s", secret: "x".repeat(32), secureCookies: false, }, data, keys: await deriveKeys("x".repeat(32)), // What apps/web/src/server/app.ts registers on the Postgres profile. onRevoked: (revoked) => revokeDerivedSessions( { registry, root, log, durable: (session) => recordSessionRevocation(root, session, { log }) }, revoked, ), }) const signIn = async (subject: string) => ( await store.open({ userId: "0199abcd-1234-7000-8000-00000000e001", issuer: "https://id.example.test", subject, tokens: { accessToken: "a", expiresAt: toInstant(Date.now() + 3600_000) }, }) ).row const runJob = () => { if (!job) throw new Error("the account module declares no revocation job") return job.def.handler(null, { container: root.child(), correlationId: "test-revoke", attempt: 1 }) } const rows = async () => (await data.global(accountSessionRevocations).list({ limit: 50 })).items const later = (ms: number) => vi.setSystemTime(Date.now() + ms) return { data, store, signIn, runJob, rows, later, warnings } } it("is scheduled by the worker every minute", () => { expect(job?.module).toBe("account") }) it("delivers at once when the platform is up, and the job has nothing left to do", async () => { const { store, signIn, runJob, rows, later } = await app() const device = await signIn("sub-up") await store.revoke(device, "sign-out") const key = sessionRefKey(kitSessionRef(device)) expect(platform.delivered).toEqual([key]) expect((await rows())[0]).toMatchObject({ state: "delivered", pending: [], delivered: expect.arrayContaining(["minting"]), }) later(10 * 60_000) await runJob() expect(platform.delivered).toEqual([key]) }) it("a platform down at sign-out gets the revocation once it is back, and nothing twice", async () => { const { data, store, signIn, runJob, rows, later, warnings } = await app() const device = await signIn("sub-down") platform.down = true // The sign-out is not blocked: it answers, and the app session is revoked. await expect(store.revoke(device, "sign-out")).resolves.toBeUndefined() expect((await data.global(sessions).get(device.id))?.revokedAt).not.toBeNull() expect(platform.delivered).toEqual([]) const [pending] = await rows() expect(pending).toMatchObject({ state: "pending", pending: ["minting"], attempts: 1, claimedUntil: null }) expect(warnings).toEqual(["auth: minting did not revoke the sessions of a revoked app session; retrying"]) // Not due yet (backoff): the job asks nobody. await runJob() expect(platform.refused).toBe(1) // Due, platform still down: one more refusal, scheduled again with a longer backoff; the run fails. later(31_000) await expect(runJob()).rejects.toMatchObject({ code: "account_revocations_waiting" }) expect(platform.refused).toBe(2) expect((await rows())[0]).toMatchObject({ state: "pending", attempts: 2 }) // Back: the next due run delivers it, once. platform.down = false later(61_000) await runJob() const key = sessionRefKey(kitSessionRef(device)) expect(platform.delivered).toEqual([key]) expect((await rows())[0]).toMatchObject({ state: "delivered", pending: [], delivered: expect.arrayContaining(["minting"]), }) later(10 * 60_000) await runJob() expect(platform.delivered).toEqual([key]) }) it("two runs at once deliver one revocation once", async () => { const { store, signIn, runJob, later } = await app() const device = await signIn("sub-race") platform.down = true await store.revoke(device, "sign-out") platform.down = false later(31_000) await Promise.all([runJob(), runJob(), runJob()]) expect(platform.delivered).toEqual([sessionRefKey(kitSessionRef(device))]) }) it("gives up once no derived session can still be alive, and removes finished rows a day later", async () => { const { store, signIn, runJob, rows, later } = await app() const down = await signIn("sub-lapse") platform.down = true await store.revoke(down, "sign-out") later(66 * 60_000) await runJob() const refused = platform.refused expect((await rows())[0]).toMatchObject({ state: "lapsed" }) later(10 * 60_000) await runJob() expect(platform.refused).toBe(refused) later(25 * 60 * 60_000) await runJob() expect(await rows()).toEqual([]) }) }) /** * The Convex profile's sign-out (apps/web/src/server/convex-revocation.ts): the web records the revoked * app session in the backend's outbox through the internal action `account:revokeSession`, with the * admin key and no identity, bounded. A refusal or a backend that does not answer is thrown, so the * hook asks the modules directly instead. */ describe("the Convex profile's durable revocation", () => { async function backend(answer: (body: { path: string; args: unknown[] }) => unknown | undefined) { const { createServer } = await import("node:http") const seen: Array<{ path: string; args: unknown[]; authorization: string | undefined }> = [] const server = createServer((request, response) => { let raw = "" request.on("data", (chunk) => { raw += chunk }) request.on("end", () => { const body = JSON.parse(raw) as { path: string; args: unknown[] } seen.push({ ...body, authorization: request.headers.authorization }) const value = answer(body) if (value === undefined) return // never answers response.writeHead(200, { "content-type": "application/json" }) response.end(JSON.stringify(value)) }) }) await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)) const address = server.address() as { port: number } return { convex: { url: `http://127.0.0.1:${address.port}`, adminKey: "app|admin-key" }, seen, close: () => new Promise((resolve) => { server.closeAllConnections() server.close(() => resolve()) }), } } it("records the revocation in the backend's outbox with the admin key", async () => { const { revokeConvexSession } = await import("../apps/web/src/server/convex-revocation") const fake = await backend(() => ({ status: "success", value: null, logLines: [] })) try { await revokeConvexSession(fake.convex, sessionRefKey(ref)) expect(fake.seen).toMatchObject([ { path: "account:revokeSession", args: [{ session: sessionRefKey(ref) }], authorization: "Convex app|admin-key", }, ]) expect(fake.seen).toHaveLength(1) } finally { await fake.close() } }) it("throws when nothing was recorded, so the hook asks the modules directly, and never waits long", async () => { const { revokeConvexSession } = await import("../apps/web/src/server/convex-revocation") const refused = await backend(() => ({ status: "error", errorMessage: "Server Error", logLines: [] })) const silent = await backend(() => undefined) try { await expect(revokeConvexSession(refused.convex, "s-1")).rejects.toBeDefined() const started = Date.now() await expect(revokeConvexSession(silent.convex, "s-2", { timeoutMs: 100 })).rejects.toBeDefined() expect(Date.now() - started).toBeLessThan(2000) } finally { await refused.close() await silent.close() } }) })