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
+1 -1
View File
@@ -98,7 +98,7 @@ Inspect live data with `bun llm-code-assistant/query-db.ts "<sql>"` - a read-onl
- **Tables:** `bots` (`kind` worker|coordinator, `hue`, `role`, `system_prompt` (the bot's core instructions, markdown)), `bot_user_faces` (`face`, `status_line` per bot and user), `bot_sessions`, `bot_messages`, `bot_tasks`, `bot_task_steps`. Enums in `src/dict/bot-dict.ts`. - **Tables:** `bots` (`kind` worker|coordinator, `hue`, `role`, `system_prompt` (the bot's core instructions, markdown)), `bot_user_faces` (`face`, `status_line` per bot and user), `bot_sessions`, `bot_messages`, `bot_tasks`, `bot_task_steps`. Enums in `src/dict/bot-dict.ts`.
- **Threads and sessions:** each user has one conversation with a bot in its own chat and one per workspace it's in (the coordinator in every workspace). Each is split into `bot_sessions` (`user_id`, `bot_id`, `workspace_id` null for its own chat, `status` active|closed, `title` and `summary` written by the bot from the first exchange, `summary_message_count` (how many messages the summary covers), `previous_session_id`, `unread`, `last_activity_at`); `bot_messages`, `bot_tasks` and `bots_queue` carry `session_id` and `workspace_id`. Helpers in `src/functions/backend/db/bot-sessions/`: `ensureBotSession` (the active one, else a new one; `rollover` starts the next once it has `AppData.BotSessionMaxMessages`), `startBotSession`, `reopenBotSession`, `grabBotSessions` (search over title, summary and message text), `grabUserConversation` (access and membership), `grabConversationProps` (the conversation pages' props). Once a session's first exchange is in, `name-bot-session.ts` has the bot name it (the title the rail and list show) and write its summary, after the reply is pushed; a new session's first reply gets the previous one's summary (`grab-session-carry-over.ts`), rewritten first when it covers fewer messages than that session ended with. The header's sessions icon (`src/components/general/bot-sessions-modal.tsx`) lists, searches, starts and reopens sessions, and the new-session icon beside it (`(partials)/new-session-button.tsx`) starts one (`api/admin/bots/list-sessions`, `new-session`, `reopen-session`). The rail lists the open bot's latest `AppData.RailRecentSessions` sessions under its row (`rail-bot-sessions.tsx`), and once there are more, "All sessions" opens the same modal. Unread, the rail's last message and activity are per thread, from its sessions. Job results go into the job's own session, even after it closes. - **Threads and sessions:** each user has one conversation with a bot in its own chat and one per workspace it's in (the coordinator in every workspace). Each is split into `bot_sessions` (`user_id`, `bot_id`, `workspace_id` null for its own chat, `status` active|closed, `title` and `summary` written by the bot from the first exchange, `summary_message_count` (how many messages the summary covers), `previous_session_id`, `unread`, `last_activity_at`); `bot_messages`, `bot_tasks` and `bots_queue` carry `session_id` and `workspace_id`. Helpers in `src/functions/backend/db/bot-sessions/`: `ensureBotSession` (the active one, else a new one; `rollover` starts the next once it has `AppData.BotSessionMaxMessages`), `startBotSession`, `reopenBotSession`, `grabBotSessions` (search over title, summary and message text), `grabUserConversation` (access and membership), `grabConversationProps` (the conversation pages' props). Once a session's first exchange is in, `name-bot-session.ts` has the bot name it (the title the rail and list show) and write its summary, after the reply is pushed; a new session's first reply gets the previous one's summary (`grab-session-carry-over.ts`), rewritten first when it covers fewer messages than that session ended with. The header's sessions icon (`src/components/general/bot-sessions-modal.tsx`) lists, searches, starts and reopens sessions, and the new-session icon beside it (`(partials)/new-session-button.tsx`) starts one (`api/admin/bots/list-sessions`, `new-session`, `reopen-session`). The rail lists the open bot's latest `AppData.RailRecentSessions` sessions under its row (`rail-bot-sessions.tsx`), and once there are more, "All sessions" opens the same modal. Unread, the rail's last message and activity are per thread, from its sessions. Job results go into the job's own session, even after it closes.
- **Coordinator:** the install has exactly one `kind: coordinator` bot, the super admin's alone, that coordinates the crew and delegates to every worker. It is created at signup (`bun run seed`) and, as a fallback, by the super admin's `/admin/bots`, all via `ensureCoordinatorBot` (`src/functions/backend/db/bots/ensure-coordinator-bot.ts`, idempotent). Name in `CoordinatorBotName` (`src/dict/bot-dict.ts`). Its default prompt is `CoordinatorSystemPrompt` (`src/data/coordinator-system-prompt.ts`): attached at creation, and backfilled onto a coordinator whose prompt is empty, never over an edited one. - **Coordinator:** the install has exactly one `kind: coordinator` bot, the super admin's alone, that coordinates the crew and delegates to every worker. It is created at signup (`bun run seed`) and, as a fallback, by the super admin's `/admin/bots`, all via `ensureCoordinatorBot` (`src/functions/backend/db/bots/ensure-coordinator-bot.ts`, idempotent). Name in `CoordinatorBotName` (`src/dict/bot-dict.ts`). Its default prompt is `CoordinatorSystemPrompt` (`src/data/coordinator-system-prompt.ts`): attached at creation, and backfilled onto a coordinator whose prompt is empty, never over an edited one.
- **Routes:** `/admin/bots` ensures the coordinator and redirects the super admin to its chat (`/admin/bots/1` on a fresh install), anyone else to their latest bot, else their first workspace, else an empty page; `/admin/bots/[bot_id]` renders the conversation (header, thread and composer, the last two held to 760px; there is no right panel); `/admin/bots/[bot_id]/models` is a worker bot's own model settings, for super admins and admins (the coordinator's redirects to its chat); `/admin/workspaces/[workspace_id]` is a workspace's dashboard (`(sections)/workspace-dashboard.tsx`: read-only picture, name, description and context, latest activity, bots with their per-workspace instructions and memory, computers; its header links into `/admin/settings/workspaces/[workspace_id]`, whose `workspace-overview.tsx` has the editable groups) and `/admin/workspaces/[workspace_id]/bots/[bot_id]` is the same `Conversation` section scoped to the workspace (`pageProps.workspace`; only the coordinator and member bots). All render in the full-bleed `src/layouts/bots/` layout (rail of conversations + user popover), which `__root.tsx` uses for every `/admin/*` page. - **Routes:** `/admin/bots` opens the conversation this browser was last in (`readLastVisitedBot` from the `cobble_last_visited_bot_id` / `..._workspace_id` cookies, which the conversation pages set through `withLastVisitedBotCookies` in their `res_transform`, and which `/auth/logout` clears), but only while `grabReachableConversation` says the user still reaches it. Otherwise it ensures the coordinator and redirects the super admin to its chat (`/admin/bots/1` on a fresh install), anyone else to their latest bot, else their first workspace, else an empty page; `/admin/bots/[bot_id]` renders the conversation (header, thread and composer, the last two held to 760px; there is no right panel); `/admin/bots/[bot_id]/models` is a worker bot's own model settings, for super admins and admins (the coordinator's redirects to its chat); `/admin/workspaces/[workspace_id]` is a workspace's dashboard (`(sections)/workspace-dashboard.tsx`: read-only picture, name, description and context, latest activity, bots with their per-workspace instructions and memory, computers; its header links into `/admin/settings/workspaces/[workspace_id]`, whose `workspace-overview.tsx` has the editable groups) and `/admin/workspaces/[workspace_id]/bots/[bot_id]` is the same `Conversation` section scoped to the workspace (`pageProps.workspace`; only the coordinator and member bots). All render in the full-bleed `src/layouts/bots/` layout (rail of conversations + user popover), which `__root.tsx` uses for every `/admin/*` page.
- **Server:** `src/functions/backend/db/bots/` (`grab-bots`, `grab-bot-messages`, `grab-active-bot-task`, `grab-user-bot`, `create-bot`, `insert-user-message`). `api/admin/bots/send-message.ts` posts a user message to a bot the session user chats with (zod: `src/schemas/bot-schema.ts`), queues the bot's reply (`enqueueBotReply`) and wakes the queue (`src/bots-queue/wake-bots-queue.ts`; never import `src/bots-queue/index.ts` from a page or API route, it pulls the bunext runtime into the page bundles). `upload-attachment.ts` stores one file for the next message (private `media` row, paradigm `bot-attachment`, type from `attachmentFileKind`, documents get `text_content`, limits in `AppData.BotAttachment*`) and `send-message` takes their `media_ids` (checked to be the user's own by `grabAttachableMedia`), linked in `bot_message_media`; messages carry `attachments` (`BotMessageWithAttachments`). `mark-read.ts` clears a session's unread when a message arrives in the open conversation; The page renders a session's latest `AppData.ConversationFirstMessages` messages (`bot_messages_more` says there are older), and the thread loads older ones `AppData.ConversationOlderMessages` at a time as it scrolls to its top (`older-messages.ts`, `use-older-messages`, `older-messages-loader.tsx`). Bot and workspace lists in page props go through `railBots` / `railWorkspaces` (`src/utils/`): no core instructions, memory or workspace context, and the last message cut to a preview (`AppData.RailPreviewMaxChars`); the header avatar's edit form loads the instructions when it opens (`bot-instructions.ts`, `use-bot-instructions`). `session-messages.ts` returns a session's latest messages, which the open conversation polls every `AppData.BotReplyCatchUpMs` while a reply is due (`use-reply-catch-up`), so a reply whose live push was missed (socket down) still lands without a reload (replayed as a `server:bot-message`, so the rail and unread take it too); `create-bot.ts` adds a worker (and puts it in a workspace when given one); `save-harness-settings.ts` saves a worker bot's models. - **Server:** `src/functions/backend/db/bots/` (`grab-bots`, `grab-bot-messages`, `grab-active-bot-task`, `grab-user-bot`, `create-bot`, `insert-user-message`). `api/admin/bots/send-message.ts` posts a user message to a bot the session user chats with (zod: `src/schemas/bot-schema.ts`), queues the bot's reply (`enqueueBotReply`) and wakes the queue (`src/bots-queue/wake-bots-queue.ts`; never import `src/bots-queue/index.ts` from a page or API route, it pulls the bunext runtime into the page bundles). `upload-attachment.ts` stores one file for the next message (private `media` row, paradigm `bot-attachment`, type from `attachmentFileKind`, documents get `text_content`, limits in `AppData.BotAttachment*`) and `send-message` takes their `media_ids` (checked to be the user's own by `grabAttachableMedia`), linked in `bot_message_media`; messages carry `attachments` (`BotMessageWithAttachments`). `mark-read.ts` clears a session's unread when a message arrives in the open conversation; The page renders a session's latest `AppData.ConversationFirstMessages` messages (`bot_messages_more` says there are older), and the thread loads older ones `AppData.ConversationOlderMessages` at a time as it scrolls to its top (`older-messages.ts`, `use-older-messages`, `older-messages-loader.tsx`). Bot and workspace lists in page props go through `railBots` / `railWorkspaces` (`src/utils/`): no core instructions, memory or workspace context, and the last message cut to a preview (`AppData.RailPreviewMaxChars`); the header avatar's edit form loads the instructions when it opens (`bot-instructions.ts`, `use-bot-instructions`). `session-messages.ts` returns a session's latest messages, which the open conversation polls every `AppData.BotReplyCatchUpMs` while a reply is due (`use-reply-catch-up`), so a reply whose live push was missed (socket down) still lands without a reload (replayed as a `server:bot-message`, so the rail and unread take it too); `create-bot.ts` adds a worker (and puts it in a workspace when given one); `save-harness-settings.ts` saves a worker bot's models.
- **Composer:** a Tiptap editor (`(hooks)/use-composer-editor.ts`, `(partials)/composer-editor.tsx`), not a textarea, loaded with the page and focused automatically, that reads and writes markdown (`@tiptap/markdown`), so drafts and sent messages are markdown. Drafts are written to `localStorage` on a 250ms debounce (`useComposerDraft`), flushed on unmount. Bold, italic, strike, code, lists, quotes and links (no headings), from shortcuts, markdown typing or the selection bubble (`composer-format-bubble.tsx`, items in `(data)/composer-formats.ts`). `@` mentions a bot and `#` a workspace (`(data)/composer-mention-kinds.ts`, menu `composer-mention-list.tsx` above the input, items from `CrewBotsContext` and `CrewWorkspacesContext` via `searchMentionItems`), shown as chips (`composer-mention-chip.tsx`) and written as `[bot:ID]` / `[workspace:ID]` (`(utils)/mention-markdown.ts`, `src/utils/workspace-ref.ts`). Enter sends, Shift+Enter breaks the line; in a list or code block Enter is the editor's and Mod+Enter sends. Sending shows the bubble at once and empties the input (with its quote and files), so a slow connection never waits on the request: the optimistic message rides in `pending_messages` in `conversation.tsx` until the response or a push replaces it with the saved one (`(utils)/drop-sent-message.ts`), and a failed send takes it back and restores the text and files. Selecting text in a message (`data-quotable`, `use-message-selection`) shows a Quote button (`quote-selection-button.tsx`) that inserts it as a blockquote at the cursor (`ComposerHandle.insertQuote`). Before a reply, `grabMessageRefNames` names the refs in the user's messages that the user reaches, and `botMessagesToHarness` adds a `[Mentions: ...]` line after each one (`describeMessageRefs`). `@tiptap/react` needs `use-sync-external-store`, which is CommonJS and calls `require("react")`; bunext keeps React external in page bundles, so the app installs an ESM stand-in from `packages/use-sync-external-store-esm` (a `file:` dependency plus an override; the Dockerfile copies `packages/` before `bun install`). - **Composer:** a Tiptap editor (`(hooks)/use-composer-editor.ts`, `(partials)/composer-editor.tsx`), not a textarea, loaded with the page and focused automatically, that reads and writes markdown (`@tiptap/markdown`), so drafts and sent messages are markdown. Drafts are written to `localStorage` on a 250ms debounce (`useComposerDraft`), flushed on unmount. Bold, italic, strike, code, lists, quotes and links (no headings), from shortcuts, markdown typing or the selection bubble (`composer-format-bubble.tsx`, items in `(data)/composer-formats.ts`). `@` mentions a bot and `#` a workspace (`(data)/composer-mention-kinds.ts`, menu `composer-mention-list.tsx` above the input, items from `CrewBotsContext` and `CrewWorkspacesContext` via `searchMentionItems`), shown as chips (`composer-mention-chip.tsx`) and written as `[bot:ID]` / `[workspace:ID]` (`(utils)/mention-markdown.ts`, `src/utils/workspace-ref.ts`). Enter sends, Shift+Enter breaks the line; in a list or code block Enter is the editor's and Mod+Enter sends. Sending shows the bubble at once and empties the input (with its quote and files), so a slow connection never waits on the request: the optimistic message rides in `pending_messages` in `conversation.tsx` until the response or a push replaces it with the saved one (`(utils)/drop-sent-message.ts`), and a failed send takes it back and restores the text and files. Selecting text in a message (`data-quotable`, `use-message-selection`) shows a Quote button (`quote-selection-button.tsx`) that inserts it as a blockquote at the cursor (`ComposerHandle.insertQuote`). Before a reply, `grabMessageRefNames` names the refs in the user's messages that the user reaches, and `botMessagesToHarness` adds a `[Mentions: ...]` line after each one (`describeMessageRefs`). `@tiptap/react` needs `use-sync-external-store`, which is CommonJS and calls `require("react")`; bunext keeps React external in page bundles, so the app installs an ESM stand-in from `packages/use-sync-external-store-esm` (a `file:` dependency plus an override; the Dockerfile copies `packages/` before `bun install`).
- **Message rendering:** bot messages are markdown, rendered with `react-markdown` + `remark-gfm` (raw HTML never rendered) through the twui-based map in `[bot_id]/(data)/message-markdown-components.tsx` (`markdown-link`, `markdown-list`, `markdown-code-block` partials). A bot names a crew member as `[bot:ID]` (`src/utils/bot-ref.ts`, linked to `#bot-ID` by `link-computer-refs.ts`): `(partials)/bot-ref-chip.tsx` shows its avatar and name, linked to its conversation (in the open workspace when it's in the crew there), from the live crew (`CrewBotsContext`), so a bot created in that reply links too; the coordinator is told to write them (`CoordinatorReplyNote`), and `find_bot` and `create_bot` return them. A workspace is named as `[workspace:ID]` (`#workspace-ID`, `(partials)/workspace-ref-chip.tsx`, from `CrewWorkspacesContext`). User messages are markdown too, rendered in the ink bubble by `user-bubble.tsx` with `UserMarkdownComponents` and `remark-breaks` (a single line break stays one, so older plain messages read as typed). - **Message rendering:** bot messages are markdown, rendered with `react-markdown` + `remark-gfm` (raw HTML never rendered) through the twui-based map in `[bot_id]/(data)/message-markdown-components.tsx` (`markdown-link`, `markdown-list`, `markdown-code-block` partials). A bot names a crew member as `[bot:ID]` (`src/utils/bot-ref.ts`, linked to `#bot-ID` by `link-computer-refs.ts`): `(partials)/bot-ref-chip.tsx` shows its avatar and name, linked to its conversation (in the open workspace when it's in the crew there), from the live crew (`CrewBotsContext`), so a bot created in that reply links too; the coordinator is told to write them (`CoordinatorReplyNote`), and `find_bot` and `create_bot` return them. A workspace is named as `[workspace:ID]` (`#workspace-ID`, `(partials)/workspace-ref-chip.tsx`, from `CrewWorkspacesContext`). User messages are markdown too, rendered in the ink bubble by `user-bubble.tsx` with `UserMarkdownComponents` and `remark-breaks` (a single line break stays one, so older plain messages read as typed).
+7
View File
@@ -10,10 +10,17 @@ describe("AppData auth constants", () => {
expect(AppData["AuthCSRFCookieName"]).toBe( expect(AppData["AuthCSRFCookieName"]).toBe(
`${SiteData["SiteSlug"]}_auth_csrf`, `${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", () => { test("expiry and limits are positive", () => {
expect(AppData["AuthExpiryDays"]).toBeGreaterThan(0); expect(AppData["AuthExpiryDays"]).toBeGreaterThan(0);
expect(AppData["LastVisitedCookieMaxAgeSeconds"]).toBeGreaterThan(0);
expect(AppData["DefaultQueryLimit"]).toBeGreaterThan(0); expect(AppData["DefaultQueryLimit"]).toBeGreaterThan(0);
}); });
+10
View File
@@ -54,6 +54,16 @@ export const AppData = {
*/ */
BuildCookieName: `${SiteData["SiteSlug"]}_build`, BuildCookieName: `${SiteData["SiteSlug"]}_build`,
BuildCookieMaxAgeSeconds: 365 * 24 * 60 * 60, 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, AuthExpiryDays: 60,
APICrudBasePath: "/api/admin/crud", APICrudBasePath: "/api/admin/crud",
ForcePrivateHeaderKey: `x-${SiteData["SiteSlug"]}-force-private`, 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;
}
+14 -2
View File
@@ -1,8 +1,13 @@
import userAuth from "@/src/functions/backend/auth/user-auth"; import userAuth from "@/src/functions/backend/auth/user-auth";
import grabConversationProps from "@/src/functions/backend/db/bot-sessions/grab-conversation-props"; 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 { PagePropsType, PageQueryObject } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/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) => { export const server: BunextPageServerFn<PagePropsType> = async (params) => {
const query: PageQueryObject = params.query; const query: PageQueryObject = params.query;
const { user } = await userAuth({ req: params.req }); const { user } = await userAuth({ req: params.req });
@@ -20,7 +25,7 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
bot_id: String(query.bot_id), bot_id: String(query.bot_id),
}); });
if (!props) { if (!props?.bot?.id) {
return { return {
redirect: { redirect: {
destination: "/admin/bots", 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 });
},
};
}; };
+39 -4
View File
@@ -1,6 +1,7 @@
import userAuth from "@/src/functions/backend/auth/user-auth"; import userAuth from "@/src/functions/backend/auth/user-auth";
import ensureCoordinatorBot from "@/src/functions/backend/db/bots/ensure-coordinator-bot"; import ensureCoordinatorBot from "@/src/functions/backend/db/bots/ensure-coordinator-bot";
import grabBots from "@/src/functions/backend/db/bots/grab-bots"; 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 ensureMainComputer from "@/src/functions/backend/db/computers/ensure-main-computer";
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces"; import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
import type { PagePropsType } from "@/src/types"; 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 type { BunextPageServerFn } from "@moduletrace/bunext/types";
import railBots from "@/src/utils/rail-bots"; import railBots from "@/src/utils/rail-bots";
import railWorkspaces from "@/src/utils/rail-workspaces"; 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 * Opens the conversation this browser was last in, from the last-visited
* (with the main computer) is created first if the install has none. * cookies the conversation pages set, while the user still reaches it.
* Anyone else lands on the latest bot they chat with, else their first * Otherwise the super admin lands on the coordinator, which (with the main
* workspace; with neither, the page says to ask an admin. * 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 }) => { export const server: BunextPageServerFn<PagePropsType> = async ({ req }) => {
const { user, user_types } = await userAuth({ 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 }); const coordinator_id = await ensureCoordinatorBot({ user_id: user.id });
await ensureMainComputer({ user_id: user.id, coordinator_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}` } }; 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([ const [bots, workspaces] = await Promise.all([
grabBots({ user_id: user.id }), grabBots({ user_id: user.id }),
grabUserWorkspaces({ 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 }) } }; 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 userAuth from "@/src/functions/backend/auth/user-auth";
import grabConversationProps from "@/src/functions/backend/db/bot-sessions/grab-conversation-props"; 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 { PagePropsType, PageQueryObject } from "@/src/types";
import canManageCrew from "@/src/utils/rbac/can-manage-crew"; import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import type { BunextPageServerFn } from "@moduletrace/bunext/types"; 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 * 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 context, computers and models. Only the coordinator and the
* workspace's bots have one; anyone else goes back to the workspace * 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) => { export const server: BunextPageServerFn<PagePropsType> = async (params) => {
const query: PageQueryObject = params.query; const query: PageQueryObject = params.query;
@@ -24,7 +26,7 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
workspace_id: String(query.workspace_id), workspace_id: String(query.workspace_id),
}); });
if (!props) { if (!props?.bot?.id) {
return { return {
redirect: { redirect: {
destination: canManageCrew({ user_types }) 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 });
},
};
}; };
+8
View File
@@ -32,6 +32,14 @@ export const server: BunextPageServerFn = async ({ req }) => {
name: AppData["AuthCSRFCookieName"], name: AppData["AuthCSRFCookieName"],
httpOnly: true, httpOnly: true,
}, },
{
name: AppData["LastVisitedBotCookieName"],
httpOnly: true,
},
{
name: AppData["LastVisitedWorkspaceCookieName"],
httpOnly: true,
},
]); ]);
return new_res; 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");
});
});
+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"]),
});
}