Let a bot and a workspace set their own envs

Envs had three scopes, general, workspace and computer. A bot had no
scope of its own, so the only way to give one worker a key was to set it
on a computer it happened to share.

The envs table gets a bot_id, and resolution now layers bot over
computer over workspace over general, so a bot's own key wins wherever it
runs. Every computer call site passes the run's bot_id, and grabRunEnvs
does the same for the {{env.KEY}} a custom tool or an MCP server's headers
read.

A bot's and a workspace's settings page can now edit their own envs, in
place, through the same save-env and delete-env routes: sending `scoped`
makes them answer with just that scope instead of the whole install's
overview. Super admins only, like the Environment page.
This commit is contained in:
2026-10-22 08:30:40 +01:00
parent 7a3d2df213
commit 96735de48f
38 changed files with 551 additions and 135 deletions
@@ -1,6 +1,6 @@
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import type { EnvsOverviewPayload } from "@/src/types";
import type { EnvsMutationResult } from "@/src/types";
type Params = {
/**
@@ -12,10 +12,12 @@ type Params = {
/**
* Posts to an env API route. Every route answers with the fresh page data
* in `singleRes`, so the page updates in place.
* in `singleRes`: the whole Environment overview, or just the one bot's or
* workspace's envs (the settings pages, `body.scoped`), so the page updates
* in place.
*/
export default async function postEnvApi({ path, body }: Params) {
return await fetchApi<Record<string, unknown>, APIResponseObject<EnvsOverviewPayload>>(
return await fetchApi<Record<string, unknown>, APIResponseObject<EnvsMutationResult>>(
`/api/admin/envs/${path}`,
{ method: "POST", body },
);
@@ -5,9 +5,9 @@ import Checkbox from "@moduletrace/twui/form/Checkbox";
import Button from "@moduletrace/twui/layout/Button";
import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import type { EnvScope, EnvsOverviewPayload, KnownEnvStatus, PublicEnv } from "@/src/types";
import SettingsStatus from "../../(partials)/settings-status";
import postEnvApi from "../(functions)/post-env-api";
import type { EnvScope, EnvsMutationResult, KnownEnvStatus, PublicEnv } from "@/src/types";
import postEnvApi from "../../(functions)/post-env-api";
import SettingsStatus from "../settings-status";
type Props = {
scope: EnvScope;
@@ -19,7 +19,7 @@ type Props = {
* Sets this app setting: its key, description and flags are fixed.
*/
known?: KnownEnvStatus;
onSaved: (overview: EnvsOverviewPayload, msg: string) => void;
onSaved: (data: EnvsMutationResult, msg: string) => void;
onCancel: () => void;
};
@@ -57,6 +57,8 @@ export default function EnvForm({ scope, env, known, onSaved, onCancel }: Props)
paradigm: scope.paradigm,
workspace_id: scope.paradigm == "workspace" ? scope.id : null,
computer_id: scope.paradigm == "computer" ? scope.id : null,
bot_id: scope.paradigm == "bot" ? scope.id : null,
scoped: scope.paradigm == "bot" || scope.paradigm == "workspace",
secret,
expose_to_computers: expose,
},
@@ -2,8 +2,8 @@ import { useState } from "react";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import type { EnvScope, EnvsOverviewPayload, KnownEnvStatus, PublicEnv } from "@/src/types";
import postEnvApi from "../(functions)/post-env-api";
import type { EnvScope, EnvsMutationResult, KnownEnvStatus, PublicEnv } from "@/src/types";
import postEnvApi from "../../(functions)/post-env-api";
import EnvForm from "./env-form";
import EnvRowActions from "./env-row-actions";
@@ -14,7 +14,12 @@ type Props = {
*/
env?: PublicEnv;
known?: KnownEnvStatus;
onChanged: (overview: EnvsOverviewPayload, msg: string) => void;
/**
* The bot or workspace's own list (its settings page) rather than the
* Environment page's overview.
*/
scoped?: boolean;
onChanged: (data: EnvsMutationResult, msg: string) => void;
onError: (msg: string) => void;
};
@@ -22,7 +27,7 @@ type Props = {
* One env: its key, description and value (or "secret"), with Edit and
* Remove. Edit opens the form in place.
*/
export default function EnvRow({ scope, env, known, onChanged, onError }: Props) {
export default function EnvRow({ scope, env, known, scoped, onChanged, onError }: Props) {
const [editing, setEditing] = useState(false);
const [busy, setBusy] = useState(false);
const key = env?.key || known?.key || "";
@@ -32,7 +37,10 @@ export default function EnvRow({ scope, env, known, onChanged, onError }: Props)
if (!window.confirm(`Remove ${key}? Computers drop it on their next run.`)) return;
setBusy(true);
const res = await postEnvApi({ path: "delete-env", body: { env_id: env.id } });
const res = await postEnvApi({
path: "delete-env",
body: { env_id: env.id, scoped },
});
setBusy(false);
if (res.success && res.singleRes) onChanged(res.singleRes, res.msg || "Removed.");
@@ -46,9 +54,9 @@ export default function EnvRow({ scope, env, known, onChanged, onError }: Props)
scope={scope}
env={env}
known={known}
onSaved={(overview, msg) => {
onSaved={(data, msg) => {
setEditing(false);
onChanged(overview, msg);
onChanged(data, msg);
}}
onCancel={() => setEditing(false)}
/>
@@ -1,11 +1,11 @@
import { useState } from "react";
import Button from "@moduletrace/twui/layout/Button";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { Plus } from "lucide-react";
import type { EnvScope, EnvsOverviewPayload, PublicEnv } from "@/src/types";
import SettingsGroup from "../../(partials)/settings-group";
import type { EnvScope, EnvsMutationResult, PublicEnv } from "@/src/types";
import SettingsGroup from "../settings-group";
import SettingsList from "../settings-list";
import SettingsStatus from "../settings-status";
import EnvForm from "./env-form";
import EnvRow from "./env-row";
@@ -15,46 +15,58 @@ type Props = {
* The scope's own envs (app settings left out).
*/
envs: PublicEnv[];
onChanged: (overview: EnvsOverviewPayload, msg: string) => void;
/**
* Set when the list is a bot's or workspace's own (its settings page)
* rather than the Environment page's overview.
*/
scoped?: boolean;
/**
* The heading, when it isn't plain "Envs".
*/
title?: string;
status?: {
error?: boolean;
success?: boolean;
msg?: string;
};
onChanged: (data: EnvsMutationResult, msg: string) => void;
onError: (msg: string) => void;
};
const Descriptions: Record<EnvScope["paradigm"], string> = {
general: "Set on every computer. A workspace or computer can set the same key to override it.",
workspace: "Set on this workspace's computers when its bots use them. Beats general, loses to a computer's own.",
computer: "Set on this computer only. Beats workspace and general.",
general: "Set everywhere. A workspace, a bot or a computer can set the same key to override it.",
workspace: "For every bot working here. Beats general, loses to the bot's own.",
computer: "For this computer only. Beats a workspace, loses to the bot's own.",
bot: "For this bot only, everywhere it works. Beats the workspace, the computer and general.",
};
/**
* The envs bots and computers use, in one scope, with an add form.
*/
export default function EnvsGroup({ scope, envs, onChanged, onError }: Props) {
export default function EnvsGroup({ scope, envs, scoped, title, status, onChanged, onError }: Props) {
const [adding, setAdding] = useState(false);
return (
<SettingsGroup title="Envs" description={Descriptions[scope.paradigm]}>
<Stack className="w-full gap-0! items-stretch">
<SettingsGroup title={title || "Envs"} description={Descriptions[scope.paradigm]}>
<SettingsList is_empty={!envs.length} empty="Nothing set here yet.">
{envs.map((env) => (
<EnvRow
key={env.id}
scope={scope}
env={env}
scoped={scoped}
onChanged={onChanged}
onError={onError}
/>
))}
{envs.length ? null : (
<Span className="text-[13px] leading-5 text-ink-secondary py-3">
Nothing set here yet.
</Span>
)}
</Stack>
</SettingsList>
<SettingsStatus status={status} />
{adding ? (
<EnvForm
scope={scope}
onSaved={(overview, msg) => {
onSaved={(data, msg) => {
setAdding(false);
onChanged(overview, msg);
onChanged(data, msg);
}}
onCancel={() => setAdding(false)}
/>
@@ -0,0 +1,42 @@
import { useState } from "react";
import type { EnvsMutationResult, ScopedEnvsPayload } from "@/src/types";
import EnvsGroup from "./envs-group";
type Props = {
/**
* A bot's own envs, or a workspace's. `null` when the viewer can't
* manage them, so the group isn't rendered at all.
*/
initial?: ScopedEnvsPayload | null;
};
/**
* The envs set on one bot or one workspace, on its settings page. Super
* admins only, like the Environment page.
*/
export default function ScopedEnvsGroup({ initial }: Props) {
const [data, setData] = useState<ScopedEnvsPayload | null>(initial || null);
const [status, setStatus] = useState<{
error?: boolean;
success?: boolean;
msg?: string;
}>();
if (!data?.can_manage) return null;
const changed = (result: EnvsMutationResult, msg: string) => {
if (result.scoped) setData(result.scoped);
setStatus({ success: true, msg });
};
return (
<EnvsGroup
scope={data.scope}
envs={data.envs}
scoped
status={status}
onChanged={changed}
onError={(msg) => setStatus({ error: true, msg })}
/>
);
}
@@ -3,6 +3,7 @@ import { AppContext } from "@/src/pages/__root";
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import type { BotWithLastMessage } from "@/src/types";
import BotHarnessForm from "../../../(partials)/bot-harness-form";
import ScopedEnvsGroup from "../../../(partials)/envs/scoped-envs-group";
import ExtensionsGroup from "../../../(partials)/extensions-group";
import SettingsSectionHeading from "../../../(partials)/settings-section-heading";
import SettingsShell from "../../../(partials)/settings-shell";
@@ -77,6 +78,7 @@ export default function BotSettingsSection() {
description="What it can use besides its own tools, everywhere it works. A workspace's own come on top while it works there."
initial={pageProps?.bot_extensions}
/>
<ScopedEnvsGroup initial={pageProps?.bot_envs} />
{is_coordinator ? null : <BotDeleteGroup bot={bot} />}
</SettingsShell>
);
@@ -4,6 +4,9 @@ import grabBots from "@/src/functions/backend/db/bots/grab-bots";
import grabUserBot from "@/src/functions/backend/db/bots/grab-user-bot";
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
import grabUserComputers from "@/src/functions/backend/db/computers/grab-user-computers";
import grabScopedEnvs from "@/src/functions/backend/db/envs/grab-scoped-envs";
import userAuth from "@/src/functions/backend/auth/user-auth";
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
import grabBotExtensionsOverview from "@/src/functions/backend/db/extensions/grab-bot-extensions-overview";
import grabBotHarnessOverview from "@/src/functions/backend/db/harness/grab-bot-harness-overview";
import grabHarnessOverview from "@/src/functions/backend/db/harness/grab-harness-overview";
@@ -40,6 +43,7 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
bot_computer_rows,
bot_harness_overview,
bot_extensions,
bot_envs,
] = await Promise.all([
grabBots({ user_id }),
grabUserWorkspaces({ user_id }),
@@ -51,6 +55,14 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
: grabBotHarnessOverview({ bot_id: bot.id })
).catch(() => null),
grabBotExtensionsOverview({ user_id, bot_id: bot.id }),
grabScopedEnvs({
paradigm: "bot",
owner_id: bot.id,
user_id,
can_manage: isPassThroughUser({
user_types: (await userAuth({ req: params.req })).user_types,
}),
}),
]);
const bot_computers = bot_computer_rows
@@ -72,6 +84,7 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
computers: all_computers,
bot_harness_overview,
bot_extensions,
bot_envs,
},
};
};
@@ -12,14 +12,14 @@ type Props = {
};
/**
* Where the envs below apply: general, a workspace or a computer. Also
* shows the last change's status.
* Where the envs below apply: general, a workspace, a bot or a computer.
* Also shows the last change's status.
*/
export default function EnvScopeSelect({ scopes, value, onChange, status }: Props) {
return (
<SettingsGroup
title="Where"
description="A computer's own envs beat its workspace's, which beat general ones."
description="A bot's own envs beat everything. Then the computer's own, then the workspace's, then general."
>
<Select
id="env-scope"
@@ -1,12 +1,12 @@
import Stack from "@moduletrace/twui/layout/Stack";
import type { EnvScope, EnvsOverviewPayload, PublicEnv } from "@/src/types";
import type { EnvScope, EnvsMutationResult, EnvsOverviewPayload, PublicEnv } from "@/src/types";
import EnvRow from "../../(partials)/envs/env-row";
import SettingsGroup from "../../(partials)/settings-group";
import EnvRow from "./env-row";
type Props = {
scope: EnvScope;
overview: EnvsOverviewPayload;
onChanged: (overview: EnvsOverviewPayload, msg: string) => void;
onChanged: (data: EnvsMutationResult, msg: string) => void;
onError: (msg: string) => void;
};
@@ -1,14 +1,14 @@
import { useContext, useState } from "react";
import { AppContext } from "@/src/pages/__root";
import type { EnvsOverviewPayload } from "@/src/types";
import type { EnvsMutationResult, EnvsOverviewPayload } from "@/src/types";
import EnvsGroup from "../../(partials)/envs/envs-group";
import EnvScopeSelect from "../(partials)/env-scope-select";
import EnvsGroup from "../(partials)/envs-group";
import KnownEnvsGroup from "../(partials)/known-envs-group";
import scopeValue, { envInScope } from "../(utils)/scope-value";
/**
* Pick where (general, a workspace, a computer), then that scope's envs.
* General also lists the app settings. Every change saves at once and
* Pick where (general, a workspace, a bot or a computer), then that scope's
* envs. General also lists the app settings. Every change saves at once and
* refreshes the page data.
*/
export default function EnvironmentSettingsSection() {
@@ -26,8 +26,8 @@ export default function EnvironmentSettingsSection() {
const scope =
overview.scopes.find((s) => scopeValue({ scope: s }) == selected) || overview.scopes[0]!;
const changed = (next: EnvsOverviewPayload, msg: string) => {
setOverview(next);
const changed = (result: EnvsMutationResult, msg: string) => {
if (result.overview) setOverview(result.overview);
setStatus({ success: true, msg });
setVersion((prev) => prev + 1);
};
@@ -9,6 +9,7 @@ const env = (fields: Partial<PublicEnv>): PublicEnv => ({
paradigm: "general",
workspace_id: null,
computer_id: null,
bot_id: null,
secret: true,
expose_to_computers: true,
known: false,
@@ -19,6 +20,7 @@ describe("scopeValue", () => {
test("names each scope", () => {
expect(scopeValue({ scope: { paradigm: "general", id: null } })).toBe("general");
expect(scopeValue({ scope: { paradigm: "computer", id: 4 } })).toBe("computer:4");
expect(scopeValue({ scope: { paradigm: "bot", id: 1 } })).toBe("bot:1");
});
});
@@ -30,4 +32,11 @@ describe("envInScope", () => {
expect(envInScope({ env: env({ paradigm: "workspace", workspace_id: 3 }), scope })).toBe(false);
expect(envInScope({ env: env({}), scope })).toBe(false);
});
test("matches a bot's own envs", () => {
const scope = { paradigm: "bot" as const, id: 1, title: "" };
expect(envInScope({ env: env({ paradigm: "bot", bot_id: 1 }), scope })).toBe(true);
expect(envInScope({ env: env({ paradigm: "bot", bot_id: 2 }), scope })).toBe(false);
});
});
@@ -1,7 +1,8 @@
import type { EnvScope, PublicEnv } from "@/src/types";
/**
* A scope as one select value: `general`, `workspace:3`, `computer:1`.
* A scope as one select value: `general`, `workspace:3`, `bot:1`,
* `computer:1`.
*/
export default function scopeValue({ scope }: { scope: Pick<EnvScope, "paradigm" | "id"> }) {
return scope.paradigm == "general" ? "general" : `${scope.paradigm}:${scope.id}`;
@@ -13,6 +14,7 @@ export default function scopeValue({ scope }: { scope: Pick<EnvScope, "paradigm"
export function envInScope({ env, scope }: { env: PublicEnv; scope: EnvScope }) {
if (env.paradigm != scope.paradigm) return false;
if (scope.paradigm == "workspace") return env.workspace_id == scope.id;
if (scope.paradigm == "bot") return env.bot_id == scope.id;
if (scope.paradigm == "computer") return env.computer_id == scope.id;
return true;
}
@@ -2,6 +2,7 @@ import { useContext, useState } from "react";
import EmptyContent from "@moduletrace/twui/elements/EmptyContent";
import { AppContext } from "@/src/pages/__root";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import ScopedEnvsGroup from "../../../(partials)/envs/scoped-envs-group";
import ExtensionsGroup from "../../../(partials)/extensions-group";
import SettingsSectionHeading from "../../../(partials)/settings-section-heading";
import SettingsShell from "../../../(partials)/settings-shell";
@@ -70,6 +71,7 @@ export default function WorkspaceOverview() {
description="Every bot working here gets these, the coordinator too, on top of its own, only while it works here."
initial={pageProps?.workspace_extensions}
/>
<ScopedEnvsGroup initial={pageProps?.workspace_envs} />
{harnesses?.length ? (
<>
<SettingsSectionHeading
@@ -2,6 +2,9 @@ import type { PagePropsType, PageQueryObject } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import grabBots from "@/src/functions/backend/db/bots/grab-bots";
import grabUserComputers from "@/src/functions/backend/db/computers/grab-user-computers";
import grabScopedEnvs from "@/src/functions/backend/db/envs/grab-scoped-envs";
import userAuth from "@/src/functions/backend/auth/user-auth";
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
import grabWorkspaceExtensionsOverview from "@/src/functions/backend/db/extensions/grab-workspace-extensions-overview";
import grabBotHarnessOverview from "@/src/functions/backend/db/harness/grab-bot-harness-overview";
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
@@ -45,6 +48,7 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
computers,
workspace_harness_overview,
workspace_extensions,
workspace_envs,
] = await Promise.all([
grabWorkspaceBots({ user_id, workspace_id }),
grabBots({ user_id }),
@@ -52,6 +56,14 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
grabUserComputers({ user_id }),
grabBotHarnessOverview({ workspace_id }).catch(() => null),
grabWorkspaceExtensionsOverview({ user_id, workspace_id }),
grabScopedEnvs({
paradigm: "workspace",
owner_id: workspace_id,
user_id,
can_manage: isPassThroughUser({
user_types: (await userAuth({ req: params.req })).user_types,
}),
}),
]);
const crew = railBots({ bots: workspace_bots });
@@ -68,6 +80,7 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
computers,
workspace_harness_overview,
workspace_extensions,
workspace_envs,
},
};
};
+32 -7
View File
@@ -3,9 +3,10 @@ import type { BUN_SQLITE_COBBLE_ENVS } from "@/db/types/db";
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
import deleteEnv from "@/src/functions/backend/db/envs/delete-env";
import grabEnvsOverview from "@/src/functions/backend/db/envs/grab-envs-overview";
import grabScopedEnvs from "@/src/functions/backend/db/envs/grab-scoped-envs";
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
import { EnvIdRequestSchema } from "@/src/schemas/env-schema";
import type { EnvsOverviewPayload } from "@/src/types";
import type { EnvsMutationResult } from "@/src/types";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import safeErrorMessage from "@/src/utils/safe-error-message";
@@ -15,10 +16,12 @@ import type {
} from "@moduletrace/bunext/types";
/**
* Super admin only: removes an env.
* Super admin only: removes an env. Answers with the fresh page data, the
* Environment overview or, from a bot's or workspace's settings page
* (`scoped`), just that scope's envs.
*/
export const handler: BunextAPIRouteHandler<
APIResponseObject<EnvsOverviewPayload>
APIResponseObject<EnvsMutationResult>
> = async ({ req, body }) => {
try {
if (req.method != "POST") {
@@ -31,11 +34,14 @@ export const handler: BunextAPIRouteHandler<
throw new AppError(`Only a super admin can change the environment.`);
}
const { env_id } = parseWithSchema({ schema: EnvIdRequestSchema, data: body });
const { env_id, scoped } = parseWithSchema({
schema: EnvIdRequestSchema,
data: body,
});
const env = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_ENVS>({
sql: `SELECT key, paradigm FROM envs WHERE id = ? LIMIT 1`,
sql: `SELECT key, paradigm, workspace_id, bot_id FROM envs WHERE id = ? LIMIT 1`,
values: [env_id],
})
).payload?.[0];
@@ -49,14 +55,33 @@ export const handler: BunextAPIRouteHandler<
category: "system",
user_id: user.id,
plain_text: `User #${user.id} removed ${env.key} (${env.paradigm}).`,
metadata: { env_id, key: env.key },
metadata: { env_id, key: env.key, paradigm: env.paradigm },
ip_address: grabRequestIP(req),
});
if (scoped && (env.paradigm == "bot" || env.paradigm == "workspace")) {
const owner_id = env.paradigm == "bot" ? env.bot_id : env.workspace_id;
if (owner_id) {
return {
success: true,
msg: `Removed ${env.key}.`,
singleRes: {
scoped: await grabScopedEnvs({
paradigm: env.paradigm,
owner_id,
user_id: Number(user.id),
can_manage: true,
}),
},
};
}
}
return {
success: true,
msg: `Removed ${env.key}.`,
singleRes: await grabEnvsOverview({ user_id: Number(user.id) }),
singleRes: { overview: await grabEnvsOverview({ user_id: Number(user.id) }) },
};
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
+55 -6
View File
@@ -1,9 +1,10 @@
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
import grabEnvsOverview from "@/src/functions/backend/db/envs/grab-envs-overview";
import grabScopedEnvs from "@/src/functions/backend/db/envs/grab-scoped-envs";
import saveEnv from "@/src/functions/backend/db/envs/save-env";
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
import { SaveEnvRequestSchema } from "@/src/schemas/env-schema";
import type { EnvsOverviewPayload } from "@/src/types";
import { SaveEnvRequestSchema, type SaveEnvRequest } from "@/src/schemas/env-schema";
import type { EnvsMutationResult } from "@/src/types";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import safeErrorMessage from "@/src/utils/safe-error-message";
@@ -14,9 +15,12 @@ import type {
/**
* Super admin only: adds or edits an env. Logged by key, never by value.
*
* Answers with the fresh page data: the Environment overview or, from a
* bot's or workspace's settings page (`scoped`), just that scope's envs.
*/
export const handler: BunextAPIRouteHandler<
APIResponseObject<EnvsOverviewPayload>
APIResponseObject<EnvsMutationResult>
> = async ({ req, body }) => {
try {
if (req.method != "POST") {
@@ -33,21 +37,66 @@ export const handler: BunextAPIRouteHandler<
await saveEnv({ ...env, user_id: Number(user.id) });
const scope_id =
env.paradigm == "workspace"
? env.workspace_id
: env.paradigm == "bot"
? env.bot_id
: env.paradigm == "computer"
? env.computer_id
: null;
await logEvent({
title: env.env_id ? "Env changed" : "Env added",
category: "system",
user_id: user.id,
plain_text: `User #${user.id} ${env.env_id ? "changed" : "added"} ${env.key} (${env.paradigm}${env.workspace_id ? ` #${env.workspace_id}` : env.computer_id ? ` #${env.computer_id}` : ""}).`,
metadata: { key: env.key, paradigm: env.paradigm, workspace_id: env.workspace_id, computer_id: env.computer_id },
plain_text: `User #${user.id} ${env.env_id ? "changed" : "added"} ${env.key} (${env.paradigm}${scope_id ? ` #${scope_id}` : ""}).`,
metadata: {
key: env.key,
paradigm: env.paradigm,
workspace_id: env.workspace_id,
computer_id: env.computer_id,
bot_id: env.bot_id,
},
ip_address: grabRequestIP(req),
});
return {
success: true,
msg: `Saved ${env.key.trim()}.`,
singleRes: await grabEnvsOverview({ user_id: Number(user.id) }),
singleRes: await mutationResult({ env, scoped: env.scoped, user_id: Number(user.id) }),
};
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};
/**
* What the page is showing after the change.
*/
async function mutationResult({
env,
scoped,
user_id,
}: {
env: SaveEnvRequest;
scoped?: boolean;
user_id: number;
}): Promise<EnvsMutationResult> {
if (scoped && (env.paradigm == "bot" || env.paradigm == "workspace")) {
const owner_id = env.paradigm == "bot" ? env.bot_id : env.workspace_id;
if (owner_id) {
return {
scoped: await grabScopedEnvs({
paradigm: env.paradigm,
owner_id,
user_id,
can_manage: true,
}),
};
}
}
return { overview: await grabEnvsOverview({ user_id }) };
}