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:
@@ -10,10 +10,17 @@ describe("AppData auth constants", () => {
|
||||
expect(AppData["AuthCSRFCookieName"]).toBe(
|
||||
`${SiteData["SiteSlug"]}_auth_csrf`,
|
||||
);
|
||||
expect(AppData["LastVisitedBotCookieName"]).toBe(
|
||||
`${SiteData["SiteSlug"]}_last_visited_bot_id`,
|
||||
);
|
||||
expect(AppData["LastVisitedWorkspaceCookieName"]).toBe(
|
||||
`${SiteData["SiteSlug"]}_last_visited_workspace_id`,
|
||||
);
|
||||
});
|
||||
|
||||
test("expiry and limits are positive", () => {
|
||||
expect(AppData["AuthExpiryDays"]).toBeGreaterThan(0);
|
||||
expect(AppData["LastVisitedCookieMaxAgeSeconds"]).toBeGreaterThan(0);
|
||||
expect(AppData["DefaultQueryLimit"]).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
|
||||
@@ -54,6 +54,16 @@ export const AppData = {
|
||||
*/
|
||||
BuildCookieName: `${SiteData["SiteSlug"]}_build`,
|
||||
BuildCookieMaxAgeSeconds: 365 * 24 * 60 * 60,
|
||||
/**
|
||||
* The conversation this browser was last in: `/admin/bots` opens it
|
||||
* again, falling back to the coordinator (super admin) or the latest
|
||||
* bot when the grant is gone. Two small ids, so the workspace one
|
||||
* stays empty for a bot's own chat. `HttpOnly`: only the server reads
|
||||
* them.
|
||||
*/
|
||||
LastVisitedBotCookieName: `${SiteData["SiteSlug"]}_last_visited_bot_id`,
|
||||
LastVisitedWorkspaceCookieName: `${SiteData["SiteSlug"]}_last_visited_workspace_id`,
|
||||
LastVisitedCookieMaxAgeSeconds: 365 * 24 * 60 * 60,
|
||||
AuthExpiryDays: 60,
|
||||
APICrudBasePath: "/api/admin/crud",
|
||||
ForcePrivateHeaderKey: `x-${SiteData["SiteSlug"]}-force-private`,
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import grabUserConversation from "./grab-user-conversation";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
bot_id: number | string;
|
||||
/**
|
||||
* Null or undefined: the bot's own chat.
|
||||
*/
|
||||
workspace_id?: number | string | null;
|
||||
};
|
||||
|
||||
export type ReachableConversation = {
|
||||
bot_id: number;
|
||||
workspace_id: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* A conversation from the last-visited cookie, only while the user still
|
||||
* reaches it: `grabUserConversation` is the same access check the
|
||||
* conversation pages use, so a revoked grant, a deleted bot or an archived
|
||||
* workspace answers null and the landing page falls back instead of
|
||||
* redirecting into a page that would bounce it back.
|
||||
*/
|
||||
export default async function grabReachableConversation({
|
||||
user_id,
|
||||
bot_id,
|
||||
workspace_id,
|
||||
}: Params): Promise<ReachableConversation | null> {
|
||||
const resolved_workspace_id = workspace_id ? Number(workspace_id) : null;
|
||||
|
||||
const conversation = await grabUserConversation({
|
||||
user_id,
|
||||
bot_id,
|
||||
workspace_id: resolved_workspace_id,
|
||||
}).catch(() => null);
|
||||
|
||||
if (!conversation?.bot.id) return null;
|
||||
|
||||
return { bot_id: conversation.bot.id, workspace_id: conversation.workspace_id };
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { setCookies } from "@/src/utils/cookies";
|
||||
import isSecureRequest from "@/src/utils/is-secure-request";
|
||||
import lastVisitedBotCookies from "@/src/utils/last-visited-bot-cookies";
|
||||
|
||||
type Params = {
|
||||
req: Request;
|
||||
res: Response;
|
||||
bot_id: number | string;
|
||||
/**
|
||||
* Null or undefined: the bot's own chat.
|
||||
*/
|
||||
workspace_id?: number | string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The page's response with the last-visited cookies set, so `/admin/bots`
|
||||
* opens this conversation again next time. Called from the conversation
|
||||
* pages' `res_transform`, which is why it takes and returns the response.
|
||||
*/
|
||||
export default function withLastVisitedBotCookies({
|
||||
req,
|
||||
res,
|
||||
bot_id,
|
||||
workspace_id,
|
||||
}: Params): Response {
|
||||
const new_res = res.clone();
|
||||
|
||||
setCookies(
|
||||
new_res,
|
||||
lastVisitedBotCookies({
|
||||
bot_id,
|
||||
workspace_id,
|
||||
secure: isSecureRequest({ req }),
|
||||
}),
|
||||
);
|
||||
|
||||
return new_res;
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import grabConversationProps from "@/src/functions/backend/db/bot-sessions/grab-conversation-props";
|
||||
import withLastVisitedBotCookies from "@/src/functions/server/with-last-visited-bot-cookies";
|
||||
import type { PagePropsType, PageQueryObject } from "@/src/types";
|
||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* A bot's own chat. Rendering it records the conversation in the
|
||||
* last-visited cookies, so `/admin/bots` opens it again.
|
||||
*/
|
||||
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||
const query: PageQueryObject = params.query;
|
||||
const { user } = await userAuth({ req: params.req });
|
||||
@@ -20,7 +25,7 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||
bot_id: String(query.bot_id),
|
||||
});
|
||||
|
||||
if (!props) {
|
||||
if (!props?.bot?.id) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: "/admin/bots",
|
||||
@@ -28,5 +33,12 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||
};
|
||||
}
|
||||
|
||||
return { props };
|
||||
const bot_id = props.bot.id;
|
||||
|
||||
return {
|
||||
props,
|
||||
res_transform(res) {
|
||||
return withLastVisitedBotCookies({ req: params.req, res, bot_id });
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import ensureCoordinatorBot from "@/src/functions/backend/db/bots/ensure-coordinator-bot";
|
||||
import grabBots from "@/src/functions/backend/db/bots/grab-bots";
|
||||
import grabReachableConversation from "@/src/functions/backend/db/bot-sessions/grab-reachable-conversation";
|
||||
import ensureMainComputer from "@/src/functions/backend/db/computers/ensure-main-computer";
|
||||
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
|
||||
import type { PagePropsType } from "@/src/types";
|
||||
@@ -8,12 +9,15 @@ import crewRole from "@/src/utils/rbac/crew-role";
|
||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
import railBots from "@/src/utils/rail-bots";
|
||||
import railWorkspaces from "@/src/utils/rail-workspaces";
|
||||
import readLastVisitedBot from "@/src/utils/read-last-visited-bot";
|
||||
|
||||
/**
|
||||
* Opens a conversation. The super admin lands on the coordinator, which
|
||||
* (with the main computer) is created first if the install has none.
|
||||
* Anyone else lands on the latest bot they chat with, else their first
|
||||
* workspace; with neither, the page says to ask an admin.
|
||||
* Opens the conversation this browser was last in, from the last-visited
|
||||
* cookies the conversation pages set, while the user still reaches it.
|
||||
* Otherwise the super admin lands on the coordinator, which (with the main
|
||||
* computer) is created first if the install has none; anyone else lands on
|
||||
* the latest bot they chat with, else their first workspace; with neither,
|
||||
* the page says to ask an admin.
|
||||
*/
|
||||
export const server: BunextPageServerFn<PagePropsType> = async ({ req }) => {
|
||||
const { user, user_types } = await userAuth({ req });
|
||||
@@ -30,9 +34,17 @@ export const server: BunextPageServerFn<PagePropsType> = async ({ req }) => {
|
||||
const coordinator_id = await ensureCoordinatorBot({ user_id: user.id });
|
||||
await ensureMainComputer({ user_id: user.id, coordinator_id });
|
||||
|
||||
const last = await grabLastVisited({ user_id: user.id, req });
|
||||
|
||||
if (last) return { redirect: { destination: last } };
|
||||
|
||||
return { redirect: { destination: `/admin/bots/${coordinator_id}` } };
|
||||
}
|
||||
|
||||
const last = await grabLastVisited({ user_id: user.id, req });
|
||||
|
||||
if (last) return { redirect: { destination: last } };
|
||||
|
||||
const [bots, workspaces] = await Promise.all([
|
||||
grabBots({ user_id: user.id }),
|
||||
grabUserWorkspaces({ user_id: user.id }),
|
||||
@@ -48,3 +60,26 @@ export const server: BunextPageServerFn<PagePropsType> = async ({ req }) => {
|
||||
|
||||
return { props: { bots: railBots({ bots }), workspaces: railWorkspaces({ workspaces }) } };
|
||||
};
|
||||
|
||||
/**
|
||||
* The path of the conversation this browser was last in, if the user still
|
||||
* reaches it (`grabReachableConversation`, the conversation pages' own
|
||||
* access check). Null sends the page on to its usual fallback.
|
||||
*/
|
||||
async function grabLastVisited({ user_id, req }: { user_id: number | string; req: Request }) {
|
||||
const visited = readLastVisitedBot({ req });
|
||||
|
||||
if (!visited) return null;
|
||||
|
||||
const conversation = await grabReachableConversation({
|
||||
user_id,
|
||||
bot_id: visited.bot_id,
|
||||
workspace_id: visited.workspace_id,
|
||||
});
|
||||
|
||||
if (!conversation) return null;
|
||||
|
||||
return conversation.workspace_id
|
||||
? `/admin/workspaces/${conversation.workspace_id}/bots/${conversation.bot_id}`
|
||||
: `/admin/bots/${conversation.bot_id}`;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import grabConversationProps from "@/src/functions/backend/db/bot-sessions/grab-conversation-props";
|
||||
import withLastVisitedBotCookies from "@/src/functions/server/with-last-visited-bot-cookies";
|
||||
import type { PagePropsType, PageQueryObject } from "@/src/types";
|
||||
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
@@ -8,7 +9,8 @@ import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
* A bot's conversation inside a workspace: the bot works with the
|
||||
* workspace's context, computers and models. Only the coordinator and the
|
||||
* workspace's bots have one; anyone else goes back to the workspace
|
||||
* settings (managers) or their bots.
|
||||
* settings (managers) or their bots. Rendering it records the conversation
|
||||
* in the last-visited cookies, so `/admin/bots` opens it again.
|
||||
*/
|
||||
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||
const query: PageQueryObject = params.query;
|
||||
@@ -24,7 +26,7 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||
workspace_id: String(query.workspace_id),
|
||||
});
|
||||
|
||||
if (!props) {
|
||||
if (!props?.bot?.id) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: canManageCrew({ user_types })
|
||||
@@ -34,5 +36,13 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||
};
|
||||
}
|
||||
|
||||
return { props };
|
||||
const bot_id = props.bot.id;
|
||||
const workspace_id = props.workspace?.id ?? query.workspace_id;
|
||||
|
||||
return {
|
||||
props,
|
||||
res_transform(res) {
|
||||
return withLastVisitedBotCookies({ req: params.req, res, bot_id, workspace_id });
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -32,6 +32,14 @@ export const server: BunextPageServerFn = async ({ req }) => {
|
||||
name: AppData["AuthCSRFCookieName"],
|
||||
httpOnly: true,
|
||||
},
|
||||
{
|
||||
name: AppData["LastVisitedBotCookieName"],
|
||||
httpOnly: true,
|
||||
},
|
||||
{
|
||||
name: AppData["LastVisitedWorkspaceCookieName"],
|
||||
httpOnly: true,
|
||||
},
|
||||
]);
|
||||
return new_res;
|
||||
},
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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"],
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user