Update frontend UI/UX
This commit is contained in:
@@ -59,7 +59,7 @@ Inspect live data with `bun llm-code-assistant/query-db.ts "<sql>"` - a read-onl
|
||||
- Redirects unauthenticated `/admin/*` → `/auth/login`
|
||||
- Unknown `/admin/settings/*` pages (not in `SettingsPages`, `src/utils/is-unknown-settings-path.ts`) redirect to `/admin/settings/general`
|
||||
- Already-authenticated users hitting `/auth/*` (except paths containing `/logout`) are redirected to `/admin/bots`; login and signup land there too
|
||||
- **Session:** encrypted auth cookie (AES-256-GCM, PBKDF2-derived key from `ENCRYPTION_KEY`/`ENCRYPTION_SALT`) plus a CSRF cookie. `userAuth` (`src/functions/backend/auth/user-auth.ts`) decrypts, checks expiry (`AuthExpiryDays`, 60) against the embedded `date`, verifies the CSRF cookie against the token embedded in the session (constant-time, `src/utils/safe-compare.ts`), and re-reads `user_types` from the DB every request (revoked users lose access immediately).
|
||||
- **Session:** encrypted auth cookie (AES-256-GCM, PBKDF2-derived key from `ENCRYPTION_KEY`/`ENCRYPTION_SALT`) plus a CSRF cookie, both `Secure` only when the request came over HTTPS (`isSecureRequest`, `src/utils/is-secure-request.ts`: the URL's scheme or `X-Forwarded-Proto`), so plain HTTP over a VPN or LAN address can still sign in. `userAuth` (`src/functions/backend/auth/user-auth.ts`) decrypts, checks expiry (`AuthExpiryDays`, 60) against the embedded `date`, verifies the CSRF cookie against the token embedded in the session (constant-time, `src/utils/safe-compare.ts`), and re-reads `user_types` from the DB every request (revoked users lose access immediately).
|
||||
- **Login:** local install, so email or username plus password only (`api/auth/password-login.ts`, rate limited), no emailed code step and no emailed password reset. Google login (`api/auth/google-login.ts`) is for existing accounts only and shows when `GOOGLE_CLIENT_ID` is set; it never creates accounts. Sessions are issued by `src/functions/backend/auth/login-user.ts` (supports `reauth` for silent refresh without audit logging).
|
||||
- **Signup:** first-run only. `api/auth/signup.tsx` refuses once any user exists, creates the super admin (`create-super-admin-user.ts`, so user #1) with a required password, and logs them in. No Google signup. `bun run seed` (needs `SEED_SUPER_ADMIN_PASSWORD`) is the headless alternative.
|
||||
- **Passwords:** bcrypt hashes live in the separate `user_passwords` table (never in `users`; blocked from the CRUD API via `src/dict/private-tables.ts`). Set/change at `/admin/settings/change-password`.
|
||||
@@ -91,7 +91,7 @@ Inspect live data with `bun llm-code-assistant/query-db.ts "<sql>"` - a read-onl
|
||||
- **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.
|
||||
- **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; `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.
|
||||
- **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). User messages stay plain text.
|
||||
- **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. User messages stay plain text.
|
||||
- **Replies and reactions:** every bot message has a row under it (`(partials)/message-actions.tsx`, handlers from `BotMessageActionsContext`) to reply or react; the user's own messages only show the bot's reactions, read-only. Replying quotes the message above the composer (`composer-reply-preview.tsx`) and the sent one carries `bot_messages.reply_to_message_id` (not a foreign key: a deleted quote only drops it); `send-message` takes any message of the same conversation (the user's, same bot and workspace, any session, so a rollover or a moved-on thread still sends), and the conversation drops a picked quote when its session changes. Messages carry `reply_to` (`grabMessagesReplies`, also on relayed messages), shown by `message-reply-quote.tsx`, and the bot reads it as `[Replying to ...]` before the user's turn (`botMessagesToHarness`). Reactions are a fixed set (`BotMessageReactions` in `src/dict/bot-dict.ts`) in `bot_message_reactions` (one row per message, emoji and user, or bot); `api/admin/bots/react-message.ts` toggles the user's own on a bot message in their sessions (`grabUserBotMessage`, `toggleMessageReaction`), and every bot gets `react_to_message` (`src/harness/tools/bot/react-to-message-tool.ts`, chat replies and tasks: a message in the session it answers, the user's latest by default). Both push the message's fresh reactions (`grabMessagesReactions`, `mine` for the session user) to the session's user as `server:bot-reaction` (`BotMessageReactionData`), which replaces the message's own; `grabBotMessages` adds them when given `user_id`. Reactions aren't relayed between processes (production runs one).
|
||||
- **Attachments and thinking:** the composer uploads files as they're picked, pasted or dropped (`use-composer-attachments`), shows them as chips, and sends once none is still uploading; the thread shows images as thumbnails and other files as chips. Replies read the files of the last `BotReplyAttachmentMessages` messages with any (`load-attachment-parts.ts`: images and audio as parts, documents as their text up to `BotAttachmentMaxTextChars`, videos as a note); older ones are only named. Tasks don't get the files yet. While the user has the last word in a session (every user message queues a reply, so this needs no face and shows even while the bot works in another session) or the bot waits for a model (`BotWaitingStatusLine`) or a task for its computer (`BotComputerBusyStatusLine`), `BotThinkingRow` sits under the last message; while a task runs it reads "<name> is working…" with the task's latest step, and a reply working through tools shows its latest step under "is thinking…" (`use-reply-progress`). A coordinator with no workers gets `CoordinatorSoloNote` instead of `CoordinatorReplyNote`, so it never claims to have handed work to a worker. Live notices come from the process that ran the job at once, and from every other process through the live relay.
|
||||
- **Voice:** the composer's mic (`(partials)/composer-voice-button.tsx`, `use-voice-recorder`, shown once the browser can record, disabled with a note while transcription has no model to run on for the bot there, `bot_voice` page prop, from `resolveHarnessCandidates`) dictates: the recording goes to `api/admin/bots/transcribe.ts` (`transcribeAudio` with the bot's models, after `grabUserConversation`) and the text lands in the input at the cursor, to edit and send; Escape cancels, `AppData.ComposerVoiceMaxMs` stops it. Each bot message has a play button (disabled with a note while speech has no model) (`speak-message-button.tsx`, one player per conversation via `SpeakMessageContext` / `use-speak-message`) that plays `GET api/admin/bots/speak-message.ts?message_id=` straight in an `<audio>` (`speakText` streamed through as the provider makes it, so it starts in a couple of seconds; a failure answers in JSON, which the hook fetches again to show; only the user's own sessions, `grabSpeakableMessage`; text from `speechText`, cut at `AppData.SpeechMaxChars`). Under Speech, every model settings form has "Hear a sample" (`settings/models/(partials)/harness-voice-preview.tsx`), which plays `AppData.VoicePreviewText` in the form's model and voice before they're saved (`GET api/admin/harness/preview-voice`, super admins and admins, that model only via `grabModelCandidate`, streamed). Neither recordings nor audio are stored. Harness errors (`HarnessError`, safe to show) come back as the buttons' titles.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import botRef from "@/src/utils/bot-ref";
|
||||
|
||||
/**
|
||||
* Prompts the helper harnesses send. The text model gets their output in
|
||||
* place of the media, so they must leave nothing out that it may need.
|
||||
@@ -36,7 +38,7 @@ Be proactive. Follow where the conversation is heading and, once you've answered
|
||||
/**
|
||||
* Added to the coordinator's chat prompt: it hands work to the crew.
|
||||
*/
|
||||
export const CoordinatorReplyNote = `As the coordinator, hand work that fits a crew member's role to them with delegate_task. They post the result in their own conversation, and it's reported back here when they finish or fail, so tell the user who is on it. When a report comes back, pass on what matters to the user, follow up on anything left, and propose what comes next: the next piece of work, which bot would take it, or a question that settles the direction. Once the user agrees, you can also grow the crew with create_bot, put a worker in a workspace with add_bot_to_workspace, and create or change a workspace with create_workspace and update_workspace.`;
|
||||
export const CoordinatorReplyNote = `As the coordinator, hand work that fits a crew member's role to them with delegate_task. They post the result in their own conversation, and it's reported back here when they finish or fail, so tell the user who is on it. When a report comes back, pass on what matters to the user, follow up on anything left, and propose what comes next: the next piece of work, which bot would take it, or a question that settles the direction. Once the user agrees, you can also grow the crew with create_bot, put a worker in a workspace with add_bot_to_workspace, and create or change a workspace with create_workspace and update_workspace. When you name a crew member, write its ref instead of its name, e.g. ${botRef({ id: 3 })}: the user sees it as the bot's name, linked to its conversation.`;
|
||||
|
||||
/**
|
||||
* Added to the coordinator's chat prompt instead while it has no workers,
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
import grabMediaURL from "../media/grab-media-url";
|
||||
import logEvent, { grabRequestIP } from "../logger";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import isSecureRequest from "@/src/utils/is-secure-request";
|
||||
|
||||
type Params = {
|
||||
user_id: string | number;
|
||||
@@ -106,17 +107,20 @@ export default async function loginUser({
|
||||
});
|
||||
|
||||
const maxAge = AppData["AuthExpiryDays"] * 24 * 60 * 60;
|
||||
const secure = req ? isSecureRequest({ req }) : true;
|
||||
|
||||
setCookies(res, [
|
||||
{
|
||||
name: AppData["AuthKeyCookieName"],
|
||||
value: encrypted_key,
|
||||
maxAge,
|
||||
secure,
|
||||
},
|
||||
{
|
||||
name: AppData["AuthCSRFCookieName"],
|
||||
value: csrf_key,
|
||||
maxAge,
|
||||
secure,
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import pushBotCreated from "@/src/bots-queue/push-bot-created";
|
||||
import createBot from "@/src/functions/backend/db/bots/create-bot";
|
||||
import addBotToWorkspace from "@/src/functions/backend/db/workspaces/add-bot-to-workspace";
|
||||
import pickFreeHue from "@/src/utils/pick-free-hue";
|
||||
import botRef from "@/src/utils/bot-ref";
|
||||
import type { HarnessTool } from "../../types";
|
||||
|
||||
type Params = {
|
||||
@@ -96,7 +97,7 @@ export default function createBotTool({
|
||||
|
||||
const where = target?.name ? ` and joins ${target.name}` : "";
|
||||
|
||||
return `${args.name} is on the crew (bot #${bot_id})${where}.${target ? "" : " It has no workspace yet: put it in one with add_bot_to_workspace, or give it a computer with assign_computer."} assign_computer, add_bot_to_workspace and delegate_task take #${bot_id} right away, in this same reply.`;
|
||||
return `${args.name} is on the crew (${botRef({ id: bot_id })})${where}.${target ? "" : " It has no workspace yet: put it in one with add_bot_to_workspace, or give it a computer with assign_computer."} assign_computer, add_bot_to_workspace and delegate_task take bot_id ${bot_id} right away, in this same reply.`;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import { z } from "zod";
|
||||
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
|
||||
import botRef from "@/src/utils/bot-ref";
|
||||
import type { HarnessTool } from "../../types";
|
||||
|
||||
type Args = {
|
||||
@@ -17,7 +18,7 @@ type Args = {
|
||||
export default function findBotTool(): HarnessTool<Args> {
|
||||
return {
|
||||
name: "find_bot",
|
||||
description: `Find crew members: by id, name, the one-line role, or words in their core instructions. Returns each match's id, name, kind and role. With no query it lists the crew, up to limit. The coordinator is listed too but can't be deleted.`,
|
||||
description: `Find crew members: by id, name, the one-line role, or words in their core instructions. Returns each match's ref (its id, e.g. [bot:3]), name, kind and role. With no query it lists the crew, up to limit. The coordinator is listed too but can't be deleted.`,
|
||||
schema: z.object({
|
||||
query: z.string().trim().max(200).optional(),
|
||||
limit: z.number().int().min(1).max(50).optional(),
|
||||
@@ -50,7 +51,7 @@ export default function findBotTool(): HarnessTool<Args> {
|
||||
return matches
|
||||
.map(
|
||||
(bot) =>
|
||||
`#${bot.id} ${bot.name} (${bot.kind}${bot.role ? `: ${bot.role}` : ""})`,
|
||||
`${botRef({ id: bot.id || 0 })} ${bot.name} (${bot.kind}${bot.role ? `: ${bot.role}` : ""})`,
|
||||
)
|
||||
.join("\n");
|
||||
},
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import React from "react";
|
||||
import type { BotWithLastMessage } from "@/src/types";
|
||||
|
||||
/**
|
||||
* The live crew the conversation sees (the workspace's in a workspace),
|
||||
* so a bot named in a message links to it, even one created just now.
|
||||
*/
|
||||
export const CrewBotsContext = React.createContext<BotWithLastMessage[]>([]);
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useContext } from "react";
|
||||
import Link from "@moduletrace/twui/layout/Link";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import BotAvatar from "@/src/components/general/bot-avatar";
|
||||
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import { CrewBotsContext } from "../(data)/crew-bots-context";
|
||||
|
||||
type Props = {
|
||||
bot_id: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* A crew member a bot named in a message (`botRef`): a link to its
|
||||
* conversation, in the open workspace when it's in the crew there, else
|
||||
* its own chat. A bot the user doesn't reach is shown by name only.
|
||||
*/
|
||||
export default function BotRefChip({ bot_id }: Props) {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const workspace_id = pageProps?.workspace?.id;
|
||||
const bots = useContext(CrewBotsContext);
|
||||
const bot = bots.find((b) => b.id == bot_id);
|
||||
|
||||
if (!bot) {
|
||||
return <Span className="font-bold">{`bot ${bot_id}`}</Span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={
|
||||
workspace_id
|
||||
? `/admin/workspaces/${workspace_id}/bots/${bot.id}`
|
||||
: `/admin/bots/${bot.id}`
|
||||
}
|
||||
title={`Open ${bot.name}`}
|
||||
className="inline-flex h-6 gap-1.5 items-center px-1.5 align-middle rounded-default-xs border-0 bg-sunken text-[13px] font-bold leading-4 text-ink whitespace-nowrap hover:opacity-80"
|
||||
>
|
||||
<BotAvatar
|
||||
image_media_id={bot.image_media_id}
|
||||
hue={(bot.hue || null) as BotHue | null}
|
||||
face={(bot.face || "idle") as BotFace}
|
||||
label={bot.name || ""}
|
||||
size={18}
|
||||
/>
|
||||
{bot.name}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import Link from "@moduletrace/twui/layout/Link";
|
||||
import { ComputerViewModes, type ComputerViewMode } from "@/src/dict/computer-dict";
|
||||
import { ComputerRefHrefPrefix, TakeOverRefHrefPrefix } from "@/src/utils/link-computer-refs";
|
||||
import { BotRefHrefPrefix, ComputerRefHrefPrefix, TakeOverRefHrefPrefix } from "@/src/utils/link-computer-refs";
|
||||
import BotRefChip from "./bot-ref-chip";
|
||||
import ComputerRefChip from "./computer-ref-chip";
|
||||
import TakeOverChip from "./take-over-chip";
|
||||
|
||||
/**
|
||||
* A link in a bot message. A computer the bot named (`linkComputerRefs`)
|
||||
* becomes its switch button, a take-over request a button that opens the
|
||||
* computer; anything else opens in a new tab.
|
||||
* computer, a bot it named a link to that bot's conversation; anything else opens in a new tab.
|
||||
*/
|
||||
export default function MarkdownLink({ href, children }: ComponentProps<"a">) {
|
||||
if (href?.startsWith(ComputerRefHrefPrefix)) {
|
||||
@@ -32,6 +33,12 @@ export default function MarkdownLink({ href, children }: ComponentProps<"a">) {
|
||||
}
|
||||
}
|
||||
|
||||
if (href?.startsWith(BotRefHrefPrefix)) {
|
||||
const bot_id = Number(href.slice(BotRefHrefPrefix.length));
|
||||
|
||||
if (bot_id) return <BotRefChip bot_id={bot_id} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
|
||||
@@ -10,6 +10,7 @@ import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||
import useLiveBots from "@/src/hooks/use-live-bots";
|
||||
import useServerEvent from "@/src/hooks/use-server-event";
|
||||
import { BotComputersContext } from "../(data)/bot-computers-context";
|
||||
import { CrewBotsContext } from "../(data)/crew-bots-context";
|
||||
import { BotMessageActionsContext } from "../(data)/message-actions-context";
|
||||
import { SpeakMessageContext } from "../(data)/speak-message-context";
|
||||
import reactToBotMessage from "../(functions)/react-to-bot-message";
|
||||
@@ -156,6 +157,7 @@ export default function Conversation() {
|
||||
if (!bot?.id || !live_bot) return null;
|
||||
|
||||
return (
|
||||
<CrewBotsContext.Provider value={bots}>
|
||||
<BotComputersContext.Provider value={bot_computers}>
|
||||
<SpeakMessageContext.Provider value={speak}>
|
||||
<BotMessageActionsContext.Provider
|
||||
@@ -206,5 +208,6 @@ export default function Conversation() {
|
||||
<ComputerViewerModal bot={live_bot} />
|
||||
</SpeakMessageContext.Provider>
|
||||
</BotComputersContext.Provider>
|
||||
</CrewBotsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -112,6 +112,7 @@ export const handler: BunextAPIRouteHandler<APIResponseObject> = async (
|
||||
if (reauth) {
|
||||
return await loginUser({
|
||||
user_id: Number(user.id),
|
||||
req,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* How a bot names a crew member in a message: `[bot:3]`. The chat shows it
|
||||
* as a link to that bot's conversation.
|
||||
*/
|
||||
export default function botRef({ id }: { id: number | string }) {
|
||||
return `[bot:${id}]`;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import isSecureRequest from "./is-secure-request";
|
||||
|
||||
describe("isSecureRequest", () => {
|
||||
test("plain HTTP on a VPN address is not secure", () => {
|
||||
const req = new Request("http://10.8.0.1:7247/api/auth/password-login");
|
||||
expect(isSecureRequest({ req })).toBe(false);
|
||||
});
|
||||
|
||||
test("an HTTPS request is secure", () => {
|
||||
const req = new Request("https://cobble.example.com/");
|
||||
expect(isSecureRequest({ req })).toBe(true);
|
||||
});
|
||||
|
||||
test("trusts the proxy's forwarded scheme", () => {
|
||||
const req = new Request("http://localhost:7247/", {
|
||||
headers: { "X-Forwarded-Proto": "https" },
|
||||
});
|
||||
expect(isSecureRequest({ req })).toBe(true);
|
||||
});
|
||||
|
||||
test("reads the first of several forwarded schemes", () => {
|
||||
const req = new Request("https://cobble.example.com/", {
|
||||
headers: { "X-Forwarded-Proto": "http, https" },
|
||||
});
|
||||
expect(isSecureRequest({ req })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
type Params = {
|
||||
req: Request;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the browser reached the app over HTTPS: the request's own scheme,
|
||||
* or the first `X-Forwarded-Proto` a proxy (Cloudflare, Caddy) set in front.
|
||||
* Plain HTTP (a WireGuard address, a LAN IP) gets cookies without `Secure`,
|
||||
* which the browser would otherwise drop.
|
||||
*/
|
||||
export default function isSecureRequest({ req }: Params): boolean {
|
||||
const forwarded = req.headers
|
||||
.get("x-forwarded-proto")
|
||||
?.split(",")[0]
|
||||
?.trim()
|
||||
.toLowerCase();
|
||||
|
||||
if (forwarded) return forwarded === "https";
|
||||
|
||||
return new URL(req.url).protocol === "https:";
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import botRef from "./bot-ref";
|
||||
import computerRef from "./computer-ref";
|
||||
import linkComputerRefs from "./link-computer-refs";
|
||||
import takeOverRef from "./take-over-ref";
|
||||
@@ -25,4 +26,12 @@ describe("linkComputerRefs", () => {
|
||||
}),
|
||||
).toBe("Please log in to Gmail. [take over](#take-over-3-browser)");
|
||||
});
|
||||
|
||||
test("links every bot a message names", () => {
|
||||
expect(
|
||||
linkComputerRefs({
|
||||
text: `${botRef({ id: 4 })} is on it. See [bot:x] and [bot:5](https://example.com).`,
|
||||
}),
|
||||
).toBe("[bot 4](#bot-4) is on it. See [bot:x] and [bot:5](https://example.com).");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,9 +12,15 @@ export const ComputerRefHrefPrefix = "#computer-";
|
||||
*/
|
||||
export const TakeOverRefHrefPrefix = "#take-over-";
|
||||
|
||||
/**
|
||||
* The link target a bot ref (`botRef`) becomes, e.g. `#bot-3`. The chat
|
||||
* renders these as links to that bot's conversation.
|
||||
*/
|
||||
export const BotRefHrefPrefix = "#bot-";
|
||||
|
||||
/**
|
||||
* Turns every computer ref (`[computer:3]`) and take-over ref
|
||||
* (`[take-over:3:browser]`) in a message into a markdown link, so it
|
||||
* (`[take-over:3:browser]`) and bot ref (`[bot:3]`) in a message into a markdown link, so it
|
||||
* survives markdown rendering as a link the chat can swap for a button.
|
||||
*/
|
||||
export default function linkComputerRefs({ text }: { text: string }) {
|
||||
@@ -26,5 +32,6 @@ export default function linkComputerRefs({ text }: { text: string }) {
|
||||
.replace(
|
||||
/\[take-over:(\d+):([a-z]+)\](?!\()/g,
|
||||
(_, id: string, mode: string) => `[take over](${TakeOverRefHrefPrefix}${id}-${mode})`,
|
||||
);
|
||||
)
|
||||
.replace(/\[bot:(\d+)\](?!\()/g, (_, id: string) => `[bot ${id}](${BotRefHrefPrefix}${id})`);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,10 @@ describe("messagePreview", () => {
|
||||
).toBe("Found it Your GitHub session is on a computer. See the docs and search_computers. one two");
|
||||
});
|
||||
|
||||
test("names bots plainly", () => {
|
||||
expect(messagePreview({ text: "[bot:4] is on it." })).toBe("a bot is on it.");
|
||||
});
|
||||
|
||||
test("keeps code text without its fences", () => {
|
||||
expect(messagePreview({ text: "Run:\n```bash\nls ~\n```" })).toBe("Run: ls ~");
|
||||
});
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
/**
|
||||
* A bot message as one line of plain text, for the rail's preview: no
|
||||
* markdown syntax, links as their text, computer refs (`computerRef`) as
|
||||
* "a computer", whitespace collapsed.
|
||||
* "a computer", bot refs (`botRef`) as "a bot", whitespace collapsed.
|
||||
*/
|
||||
export default function messagePreview({ text }: { text: string }) {
|
||||
return text
|
||||
.replace(/```[^\n]*\n?/g, "")
|
||||
.replace(/\[computer:\d+\](?!\()/g, "a computer")
|
||||
.replace(/\[bot:\d+\](?!\()/g, "a bot")
|
||||
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
|
||||
.replace(/^\s{0,3}(#{1,6}\s+|>\s?|[-*+]\s+|\d+\.\s+)/gm, "")
|
||||
.replace(/(\*\*|__|~~|`)/g, "")
|
||||
|
||||
Reference in New Issue
Block a user