Open the bot last visited, else the coordinator

`/admin/bots` sent the super admin to the coordinator and everyone else to
their latest bot, which lost the thread each time the tab was reopened. The
conversation pages now record where the browser is in two HttpOnly cookies
(`cobble_last_visited_bot_id`, `..._workspace_id`, a year, `Secure` only over
HTTPS), and the landing page opens that conversation again, in its workspace
when it was one.

A stale cookie can't loop: `grabReachableConversation` re-checks it with
`grabUserConversation`, the gate the conversation pages use, so a revoked
grant, a deleted bot or an archived workspace falls back to the coordinator
(super admin) or the latest bot, first workspace, empty page. Signing out
clears both cookies so the next user on that browser doesn't land in the
previous one's conversation.
This commit is contained in:
2026-10-22 11:18:19 +01:00
parent a8d8b680ad
commit 16f1e7904a
14 changed files with 402 additions and 10 deletions
@@ -0,0 +1,79 @@
import { describe, expect, test } from "bun:test";
import { AppData } from "@/src/data/app-data";
import lastVisitedBotCookies from "./last-visited-bot-cookies";
import { setCookies } from "./cookies";
import readLastVisitedBot from "./read-last-visited-bot";
const bot_cookie = AppData["LastVisitedBotCookieName"];
const workspace_cookie = AppData["LastVisitedWorkspaceCookieName"];
function requestWithCookies(): Request {
const res = new Response();
setCookies(
res,
lastVisitedBotCookies({ bot_id: 4, workspace_id: 9, secure: false }),
);
const cookie_header = res.headers
.getSetCookie()
.map((header) => header.split(";")[0])
.join("; ");
return new Request("http://localhost/admin/bots", {
headers: { Cookie: cookie_header },
});
}
describe("last visited bot cookies", () => {
test("round-trip a workspace conversation", () => {
expect(readLastVisitedBot({ req: requestWithCookies() })).toEqual({
bot_id: 4,
workspace_id: 9,
});
});
test("a bot's own chat leaves the workspace cookie empty", () => {
const res = new Response();
setCookies(res, lastVisitedBotCookies({ bot_id: 4, secure: false }));
const cookie_header = res.headers
.getSetCookie()
.map((header) => header.split(";")[0])
.join("; ");
const req = new Request("http://localhost", {
headers: { Cookie: cookie_header },
});
expect(readLastVisitedBot({ req })).toEqual({ bot_id: 4, workspace_id: null });
});
test("no cookies at all", () => {
expect(readLastVisitedBot({ req: new Request("http://localhost") })).toBeNull();
});
test("only the workspace cookie names no conversation", () => {
const req = new Request("http://localhost", {
headers: { Cookie: `${workspace_cookie}=9` },
});
expect(readLastVisitedBot({ req })).toBeNull();
});
test("a tampered bot id is ignored", () => {
const req = new Request("http://localhost", {
headers: { Cookie: `${bot_cookie}=1%20OR%201=1` },
});
expect(readLastVisitedBot({ req })).toBeNull();
});
test("they are HttpOnly, long-lived and not Secure on plain HTTP", () => {
const res = new Response();
setCookies(
res,
lastVisitedBotCookies({ bot_id: 4, secure: false }),
);
const [header] = res.headers.getSetCookie();
expect(header).toContain("HttpOnly");
expect(header).toContain(`Max-Age=${AppData["LastVisitedCookieMaxAgeSeconds"]}`);
expect(header).not.toContain("Secure");
});
});
+41
View File
@@ -0,0 +1,41 @@
import { AppData } from "@/src/data/app-data";
import type { CookieOptions } from "./cookies";
type Params = {
bot_id: number | string;
/**
* Null or undefined: the bot's own chat.
*/
workspace_id?: number | string | null;
/**
* From `isSecureRequest`, so plain HTTP over a VPN still keeps them.
*/
secure: boolean;
};
/**
* The cookies that remember the conversation this browser is in, for
* `setCookies`: read back by `readLastVisitedBot` on the bots' landing page.
* `HttpOnly`, since only the server reads them, and they hold nothing but
* two ids.
*/
export default function lastVisitedBotCookies({
bot_id,
workspace_id,
secure,
}: Params): CookieOptions[] {
return [
{
name: AppData["LastVisitedBotCookieName"],
value: String(bot_id),
secure,
maxAge: AppData["LastVisitedCookieMaxAgeSeconds"],
},
{
name: AppData["LastVisitedWorkspaceCookieName"],
value: workspace_id ? String(workspace_id) : "",
secure,
maxAge: AppData["LastVisitedCookieMaxAgeSeconds"],
},
];
}
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, test } from "bun:test";
import parseLastVisitedBot from "./parse-last-visited-bot";
describe("parseLastVisitedBot", () => {
test("a bot's own chat", () => {
expect(parseLastVisitedBot({ bot_id: "3" })).toEqual({ bot_id: 3, workspace_id: null });
expect(parseLastVisitedBot({ bot_id: 3, workspace_id: "" })).toEqual({
bot_id: 3,
workspace_id: null,
});
expect(parseLastVisitedBot({ bot_id: 3, workspace_id: null })).toEqual({
bot_id: 3,
workspace_id: null,
});
});
test("a workspace conversation", () => {
expect(parseLastVisitedBot({ bot_id: "3", workspace_id: "7" })).toEqual({
bot_id: 3,
workspace_id: 7,
});
});
test("rejects ids that aren't positive whole numbers", () => {
expect(parseLastVisitedBot({})).toBeNull();
expect(parseLastVisitedBot({ bot_id: "" })).toBeNull();
expect(parseLastVisitedBot({ bot_id: "0" })).toBeNull();
expect(parseLastVisitedBot({ bot_id: "-1" })).toBeNull();
expect(parseLastVisitedBot({ bot_id: "1.5" })).toBeNull();
expect(parseLastVisitedBot({ bot_id: "abc" })).toBeNull();
expect(parseLastVisitedBot({ bot_id: "1 OR 1=1" })).toBeNull();
expect(parseLastVisitedBot({ bot_id: "99999999999999999999" })).toBeNull();
});
test("a bad workspace falls back to nothing, never to its own chat", () => {
expect(parseLastVisitedBot({ bot_id: "3", workspace_id: "abc" })).toBeNull();
expect(parseLastVisitedBot({ bot_id: "3", workspace_id: "0" })).toBeNull();
});
test("trims what a cookie header may carry", () => {
expect(parseLastVisitedBot({ bot_id: " 3 ", workspace_id: " 7 " })).toEqual({
bot_id: 3,
workspace_id: 7,
});
});
});
+46
View File
@@ -0,0 +1,46 @@
export type LastVisitedBot = {
bot_id: number;
/**
* Null: the bot's own chat.
*/
workspace_id: number | null;
};
type Params = {
bot_id?: string | number | null;
workspace_id?: string | number | null;
};
/**
* The conversation the last-visited cookies name, or null when they don't
* name one. Ids are checked to be positive whole numbers, so a hand-edited
* cookie never reaches a query; whether the user still reaches that
* conversation is the caller's check.
*/
export default function parseLastVisitedBot({ bot_id, workspace_id }: Params): LastVisitedBot | null {
const bot = parseId(bot_id);
if (bot == null) return null;
if (workspace_id === undefined || workspace_id === null || workspace_id === "") {
return { bot_id: bot, workspace_id: null };
}
const workspace = parseId(workspace_id);
if (workspace == null) return null;
return { bot_id: bot, workspace_id: workspace };
}
function parseId(value?: string | number | null): number | null {
if (value === undefined || value === null) return null;
const raw = String(value).trim();
if (!/^\d+$/.test(raw)) return null;
const id = Number(raw);
return Number.isSafeInteger(id) && id > 0 ? id : null;
}
+20
View File
@@ -0,0 +1,20 @@
import { AppData } from "@/src/data/app-data";
import { getCookie } from "./cookies";
import parseLastVisitedBot, { type LastVisitedBot } from "./parse-last-visited-bot";
type Params = {
req: Request;
};
/**
* The conversation this browser was last in, from the last-visited cookies
* (see `lastVisitedBotCookies`). Null when it has none, or when they don't
* name a conversation. Whether the user still reaches it is the caller's
* check: a stale grant must fall back, never redirect.
*/
export default function readLastVisitedBot({ req }: Params): LastVisitedBot | null {
return parseLastVisitedBot({
bot_id: getCookie(req, AppData["LastVisitedBotCookieName"]),
workspace_id: getCookie(req, AppData["LastVisitedWorkspaceCookieName"]),
});
}