Move secrets between users and computers without the model seeing them
A new personal env scope (paradigm user: one user's secret for one bot) beats every other scope, reaches only that user's runs of that bot, and is redacted like any secret. Bots take secrets in with request_secret, which puts a [secret-request:ID] card in the chat: the user types the value into a masked input there, never into a message, and the bot is told only the key. Bots show secrets with capture_secret (a task's computer tool that saves a file or a command's output straight to a personal env, then redacts it from the rest of the run) and show_secret (an env by key): both give a [secret:ID] chip whose Reveal and Copy fetch the value on click, for its own user (personal) or a super admin (anything else), logged by key. Values never go into messages, page props, history, Copy chat or logs. Users manage their own secrets on Settings > Secrets; the Environment page lists everyone's, remove only. Deleting a bot or user removes its personal secrets, and SecretsNote tells bots never to ask for or print secrets in chat. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -3,6 +3,7 @@ import deleteMedia from "@/src/functions/backend/db/media/delete-media";
|
||||
import purgeStorage, { grabStorageMediaIds } from "@/src/functions/backend/db/bot-files/purge-storage";
|
||||
import { grabTaskMediaIds } from "@/src/functions/backend/db/tasks/purge-task-media";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import { clearEnvCache } from "../envs/grab-all-envs";
|
||||
import grabUserBot from "./grab-user-bot";
|
||||
import assertCrewManager from "@/src/functions/backend/db/access/assert-crew-manager";
|
||||
|
||||
@@ -13,7 +14,8 @@ type Params = {
|
||||
|
||||
/**
|
||||
* Deletes one of the user's worker bots with its conversations, tasks,
|
||||
* jobs, models and workspace places. A job it's running stops: it loses
|
||||
* jobs, models, envs (users' personal secrets for it too) and workspace
|
||||
* places. A job it's running stops: it loses
|
||||
* its row. The coordinator can't be deleted.
|
||||
*/
|
||||
export default async function deleteBot({ user_id, bot_id }: Params) {
|
||||
@@ -42,6 +44,8 @@ export default async function deleteBot({ user_id, bot_id }: Params) {
|
||||
* Explicitly, since foreign keys are off outside the server process.
|
||||
*/
|
||||
await BunSQLite.sql({ sql: `DELETE FROM bot_types WHERE bot_id = ?`, values: [bot.id] });
|
||||
await BunSQLite.sql({ sql: `DELETE FROM envs WHERE bot_id = ?`, values: [bot.id] });
|
||||
await BunSQLite.sql({ sql: `DELETE FROM secret_requests WHERE bot_id = ?`, values: [bot.id] });
|
||||
|
||||
const res = await BunSQLite.sql({ sql: `DELETE FROM bots WHERE id = ?`, values: [bot.id] });
|
||||
|
||||
@@ -49,6 +53,8 @@ export default async function deleteBot({ user_id, bot_id }: Params) {
|
||||
throw new Error(`Couldn't delete the bot: ${res.error}`);
|
||||
}
|
||||
|
||||
clearEnvCache();
|
||||
|
||||
if (bot.image_media_id) {
|
||||
await deleteMedia({ id: bot.image_media_id, user_id: Number(user_id) }).catch(() => {});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import deleteEnv from "./delete-env";
|
||||
import grabAllEnvs from "./grab-all-envs";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
env_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes one of the user's own personal secrets. Returns its key, for the
|
||||
* log. Computers drop it on their next run.
|
||||
*/
|
||||
export default async function deleteUserSecret({ user_id, env_id }: Params) {
|
||||
const env = (await grabAllEnvs()).find(
|
||||
(e) => e.id == Number(env_id) && e.paradigm == "user" && e.user_id == Number(user_id),
|
||||
);
|
||||
|
||||
if (!env) throw new AppError(`That secret no longer exists.`);
|
||||
|
||||
await deleteEnv({ env_id: env.id });
|
||||
|
||||
return env.key;
|
||||
}
|
||||
@@ -13,6 +13,10 @@ export type DecryptedEnv = {
|
||||
workspace_id: number | null;
|
||||
computer_id: number | null;
|
||||
bot_id: number | null;
|
||||
/**
|
||||
* The user, for a personal env (paradigm user, with `bot_id`).
|
||||
*/
|
||||
user_id: number | null;
|
||||
secret: boolean;
|
||||
expose_to_computers: boolean;
|
||||
};
|
||||
@@ -70,6 +74,7 @@ async function load() {
|
||||
workspace_id: typeof row.workspace_id == "number" ? row.workspace_id : null,
|
||||
computer_id: typeof row.computer_id == "number" ? row.computer_id : null,
|
||||
bot_id: typeof row.bot_id == "number" ? row.bot_id : null,
|
||||
user_id: typeof row.user_id == "number" ? row.user_id : null,
|
||||
secret: row.secret != 0,
|
||||
expose_to_computers: row.expose_to_computers != 0,
|
||||
})),
|
||||
|
||||
@@ -8,6 +8,10 @@ type Params = {
|
||||
* The bot the run is for: its own envs beat everything else.
|
||||
*/
|
||||
bot_id?: number | string | null;
|
||||
/**
|
||||
* The user the run is for: their personal envs for the bot apply.
|
||||
*/
|
||||
user_id?: number | string | null;
|
||||
/**
|
||||
* The workspace the run is in. Its envs apply when the computer is
|
||||
* linked to it (and it isn't archived); outside a workspace only the
|
||||
@@ -19,7 +23,7 @@ type Params = {
|
||||
/**
|
||||
* The envs a run on this computer gets (`resolveScopedEnvs`).
|
||||
*/
|
||||
export default async function grabComputerEnvs({ computer_id, bot_id, workspace_id }: Params) {
|
||||
export default async function grabComputerEnvs({ computer_id, bot_id, user_id, workspace_id }: Params) {
|
||||
const linked = workspace_id
|
||||
? (
|
||||
await BunSQLite.sql<{ id: number }>({
|
||||
@@ -34,6 +38,7 @@ export default async function grabComputerEnvs({ computer_id, bot_id, workspace_
|
||||
return resolveScopedEnvs({
|
||||
envs: await grabAllEnvs(),
|
||||
bot_id,
|
||||
user_id,
|
||||
computer_id: Number(computer_id),
|
||||
workspace_ids: linked?.id ? [Number(linked.id)] : [],
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
import { EnvDict } from "@/src/dict/env-dict";
|
||||
import type { EnvsOverviewPayload } from "@/src/types";
|
||||
import grabAllEnvs, { type DecryptedEnv } from "./grab-all-envs";
|
||||
import grabPersonalSecrets from "./grab-personal-secrets";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
@@ -19,10 +20,11 @@ type Params = {
|
||||
/**
|
||||
* The Environment settings page: where envs can be set (general, every
|
||||
* workspace, bot and computer), every env (secret values left out),
|
||||
* and the app settings with where each comes from.
|
||||
* the app settings with where each comes from, and every user's personal
|
||||
* secrets (set from the chat, listed apart, values never shown).
|
||||
*/
|
||||
export default async function grabEnvsOverview(_params: Params): Promise<EnvsOverviewPayload> {
|
||||
const [workspaces, bots, computers, envs] = await Promise.all([
|
||||
const [workspaces, bots, computers, envs, personal] = await Promise.all([
|
||||
BunSQLite.sql<BUN_SQLITE_COBBLE_WORKSPACES>({
|
||||
sql: `SELECT id, name, archived FROM workspaces ORDER BY archived ASC, id ASC`,
|
||||
}),
|
||||
@@ -33,6 +35,7 @@ export default async function grabEnvsOverview(_params: Params): Promise<EnvsOve
|
||||
sql: `SELECT id, name, kind FROM computers ORDER BY kind = 'main' DESC, id ASC`,
|
||||
}),
|
||||
grabAllEnvs(),
|
||||
grabPersonalSecrets(),
|
||||
]);
|
||||
|
||||
const known_keys = new Set(EnvDict.map((env) => env.key));
|
||||
@@ -58,7 +61,8 @@ export default async function grabEnvsOverview(_params: Params): Promise<EnvsOve
|
||||
})),
|
||||
].sort(compareScopes),
|
||||
],
|
||||
envs: envs.map((env) => publicEnv({ env, known_keys })),
|
||||
envs: envs.filter((env) => env.paradigm != "user").map((env) => publicEnv({ env, known_keys })),
|
||||
personal,
|
||||
known: EnvDict.map((env) => ({
|
||||
...env,
|
||||
saved: envs.some((saved) => saved.paradigm == "general" && saved.key == env.key),
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { PersonalSecret } from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* Only this user's. Left out lists everyone's, for the super admin's
|
||||
* Environment page.
|
||||
*/
|
||||
user_id?: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Personal secrets (envs with paradigm `user`) as settings pages list
|
||||
* them: key, description, bot and user, never the value.
|
||||
*/
|
||||
export default async function grabPersonalSecrets({ user_id }: Params = {}): Promise<PersonalSecret[]> {
|
||||
const res = await BunSQLite.sql<PersonalSecret>({
|
||||
sql: `SELECT e.id, e.key, e.description, e.bot_id, e.user_id, e.updated_at,
|
||||
COALESCE(b.name, 'Bot #' || e.bot_id) AS bot_name,
|
||||
COALESCE(NULLIF(TRIM(COALESCE(u.first_name, '') || ' ' || COALESCE(u.last_name, '')), ''), u.username, u.email, 'User #' || e.user_id) AS user_name
|
||||
FROM envs e
|
||||
LEFT JOIN bots b ON b.id = e.bot_id
|
||||
LEFT JOIN users u ON u.id = e.user_id
|
||||
WHERE e.paradigm = 'user' ${user_id ? "AND e.user_id = ?" : ""}
|
||||
ORDER BY bot_name COLLATE NOCASE ASC, e.key ASC`,
|
||||
values: user_id ? [Number(user_id)] : [],
|
||||
});
|
||||
|
||||
return res.payload || [];
|
||||
}
|
||||
@@ -11,6 +11,7 @@ const env = (fields: Partial<DecryptedEnv> & { key: string; value: string }): De
|
||||
workspace_id: null,
|
||||
computer_id: null,
|
||||
bot_id: null,
|
||||
user_id: null,
|
||||
secret: true,
|
||||
expose_to_computers: true,
|
||||
...fields,
|
||||
@@ -81,4 +82,20 @@ describe("resolveScopedEnvs", () => {
|
||||
|
||||
expect(resolveScopedEnvs({ envs, computer_id: 7, workspace_ids: [] })).toEqual([]);
|
||||
});
|
||||
|
||||
test("a user's personal env beats the bot's own, for that user and bot only", () => {
|
||||
const envs = [
|
||||
env({ key: "TOKEN", value: "bot", paradigm: "bot", bot_id: 4 }),
|
||||
env({ key: "TOKEN", value: "mine", paradigm: "user", bot_id: 4, user_id: 9 }),
|
||||
env({ key: "OTHER_BOT", value: "no", paradigm: "user", bot_id: 5, user_id: 9 }),
|
||||
env({ key: "OTHER_USER", value: "no", paradigm: "user", bot_id: 4, user_id: 10 }),
|
||||
];
|
||||
|
||||
expect(values(resolveScopedEnvs({ envs, bot_id: 4, user_id: 9, computer_id: 7 }))).toEqual({
|
||||
TOKEN: "mine",
|
||||
});
|
||||
expect(values(resolveScopedEnvs({ envs, bot_id: 4, computer_id: 7 }))).toEqual({
|
||||
TOKEN: "bot",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,11 @@ type Params = {
|
||||
* The bot the run is for: its own envs beat everything else.
|
||||
*/
|
||||
bot_id?: number | string | null;
|
||||
/**
|
||||
* The user the run is for: their personal envs for this bot beat the
|
||||
* bot's own.
|
||||
*/
|
||||
user_id?: number | string | null;
|
||||
computer_id?: number | string | null;
|
||||
/**
|
||||
* The workspaces whose envs apply, in order of precedence (first wins).
|
||||
@@ -15,7 +20,8 @@ type Params = {
|
||||
};
|
||||
|
||||
/**
|
||||
* The envs that reach one run, one per key: the bot's own beat the
|
||||
* The envs that reach one run, one per key: the user's personal ones for
|
||||
* this bot beat the bot's own, which beat the
|
||||
* computer's, which beat its workspaces' (the first listed workspace wins
|
||||
* a tie), which beat general ones. Only envs exposed to computers; app
|
||||
* settings never are.
|
||||
@@ -23,6 +29,7 @@ type Params = {
|
||||
export default function resolveScopedEnvs({
|
||||
envs,
|
||||
bot_id,
|
||||
user_id,
|
||||
computer_id,
|
||||
workspace_ids = [],
|
||||
}: Params): DecryptedEnv[] {
|
||||
@@ -47,6 +54,14 @@ export default function resolveScopedEnvs({
|
||||
bot_id
|
||||
? exposed.filter((env) => env.paradigm == "bot" && env.bot_id == Number(bot_id))
|
||||
: [],
|
||||
bot_id && user_id
|
||||
? exposed.filter(
|
||||
(env) =>
|
||||
env.paradigm == "user" &&
|
||||
env.bot_id == Number(bot_id) &&
|
||||
env.user_id == Number(user_id),
|
||||
)
|
||||
: [],
|
||||
];
|
||||
|
||||
for (const layer of layers) {
|
||||
|
||||
@@ -29,6 +29,10 @@ type Params = {
|
||||
workspace_id?: number | null;
|
||||
computer_id?: number | null;
|
||||
bot_id?: number | null;
|
||||
/**
|
||||
* The user a personal env (paradigm user, with `bot_id`) is for.
|
||||
*/
|
||||
owner_user_id?: number | null;
|
||||
secret: boolean;
|
||||
expose_to_computers: boolean;
|
||||
};
|
||||
@@ -37,7 +41,7 @@ type Params = {
|
||||
* Adds or edits an env. One per key and scope: SQLite unique constraints
|
||||
* treat NULL ids as distinct, so the check is here. App settings
|
||||
* (`EnvDict`) are general only, never go to computers, and keep their
|
||||
* dict's secret flag.
|
||||
* dict's secret flag. Returns the env's id.
|
||||
*/
|
||||
export default async function saveEnv({
|
||||
user_id,
|
||||
@@ -49,9 +53,10 @@ export default async function saveEnv({
|
||||
workspace_id,
|
||||
computer_id,
|
||||
bot_id,
|
||||
owner_user_id,
|
||||
secret,
|
||||
expose_to_computers,
|
||||
}: Params) {
|
||||
}: Params): Promise<number> {
|
||||
const final_key = key.trim();
|
||||
|
||||
if (!EnvKeyRegex.test(final_key)) {
|
||||
@@ -68,13 +73,13 @@ export default async function saveEnv({
|
||||
throw new AppError(`${final_key} is an app setting, so it can only be set in General.`);
|
||||
}
|
||||
|
||||
const scope = await resolveScope({ user_id, paradigm, workspace_id, computer_id, bot_id });
|
||||
const scope = await resolveScope({ user_id, paradigm, workspace_id, computer_id, bot_id, owner_user_id });
|
||||
|
||||
const duplicate = (
|
||||
await BunSQLite.sql<BUN_SQLITE_COBBLE_ENVS>({
|
||||
sql: `SELECT id FROM envs WHERE key = ? AND paradigm = ?
|
||||
AND COALESCE(workspace_id, 0) = ? AND COALESCE(computer_id, 0) = ?
|
||||
AND COALESCE(bot_id, 0) = ?
|
||||
AND COALESCE(bot_id, 0) = ? AND COALESCE(user_id, 0) = ?
|
||||
AND id != ? LIMIT 1`,
|
||||
values: [
|
||||
final_key,
|
||||
@@ -82,6 +87,7 @@ export default async function saveEnv({
|
||||
scope.workspace_id ?? 0,
|
||||
scope.computer_id ?? 0,
|
||||
scope.bot_id ?? 0,
|
||||
scope.user_id ?? 0,
|
||||
env_id ?? 0,
|
||||
],
|
||||
})
|
||||
@@ -98,6 +104,7 @@ export default async function saveEnv({
|
||||
workspace_id: scope.workspace_id,
|
||||
computer_id: scope.computer_id,
|
||||
bot_id: scope.bot_id,
|
||||
user_id: scope.user_id,
|
||||
secret: (known ? known.secret : secret) ? 1 : 0,
|
||||
expose_to_computers: !known && expose_to_computers ? 1 : 0,
|
||||
};
|
||||
@@ -126,6 +133,10 @@ export default async function saveEnv({
|
||||
});
|
||||
|
||||
if (!res.success) throw new Error(`Couldn't save the env: ${res.error}`);
|
||||
|
||||
clearEnvCache();
|
||||
|
||||
return env_id;
|
||||
} else {
|
||||
if (value === undefined) {
|
||||
throw new AppError(`Enter a value.`);
|
||||
@@ -134,16 +145,18 @@ export default async function saveEnv({
|
||||
const row = { ...fields, value: await encrypt(value) };
|
||||
const now = Date.now();
|
||||
|
||||
const res = await BunSQLite.sql({
|
||||
const res = await BunSQLite.sql<{ id: number }>({
|
||||
sql: `INSERT INTO envs (created_at, updated_at, ${Object.keys(row).join(", ")})
|
||||
VALUES (?, ?, ${Object.keys(row).map(() => "?").join(", ")})`,
|
||||
VALUES (?, ?, ${Object.keys(row).map(() => "?").join(", ")}) RETURNING id`,
|
||||
values: [now, now, ...Object.values(row)] as any[],
|
||||
});
|
||||
|
||||
if (!res.success) throw new Error(`Couldn't save the env: ${res.error}`);
|
||||
}
|
||||
|
||||
clearEnvCache();
|
||||
clearEnvCache();
|
||||
|
||||
return Number(res.payload?.[0]?.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveScope({
|
||||
@@ -152,15 +165,30 @@ async function resolveScope({
|
||||
workspace_id,
|
||||
computer_id,
|
||||
bot_id,
|
||||
owner_user_id,
|
||||
}: {
|
||||
user_id: number;
|
||||
paradigm: EnvParadigm;
|
||||
workspace_id?: number | null;
|
||||
computer_id?: number | null;
|
||||
bot_id?: number | null;
|
||||
}) {
|
||||
owner_user_id?: number | null;
|
||||
}): Promise<{
|
||||
workspace_id: number | null;
|
||||
computer_id: number | null;
|
||||
bot_id: number | null;
|
||||
user_id: number | null;
|
||||
}> {
|
||||
if (paradigm == "general") {
|
||||
return { workspace_id: null, computer_id: null, bot_id: null };
|
||||
return { workspace_id: null, computer_id: null, bot_id: null, user_id: null };
|
||||
}
|
||||
|
||||
if (paradigm == "user") {
|
||||
if (!owner_user_id) throw new AppError(`A personal env needs its user.`);
|
||||
|
||||
const scope = await resolveScope({ user_id, paradigm: "bot", bot_id });
|
||||
|
||||
return { ...scope, user_id: Number(owner_user_id) };
|
||||
}
|
||||
|
||||
const table =
|
||||
@@ -181,8 +209,8 @@ async function resolveScope({
|
||||
}
|
||||
|
||||
return paradigm == "workspace"
|
||||
? { workspace_id: Number(id), computer_id: null, bot_id: null }
|
||||
? { workspace_id: Number(id), computer_id: null, bot_id: null, user_id: null }
|
||||
: paradigm == "bot"
|
||||
? { workspace_id: null, computer_id: null, bot_id: Number(id) }
|
||||
: { workspace_id: null, computer_id: Number(id), bot_id: null };
|
||||
? { workspace_id: null, computer_id: null, bot_id: Number(id), user_id: null }
|
||||
: { workspace_id: null, computer_id: Number(id), bot_id: null, user_id: null };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import grabUserBot from "../bots/grab-user-bot";
|
||||
import grabAllEnvs from "./grab-all-envs";
|
||||
import saveEnv from "./save-env";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* Whose secret it is: only their runs of the bot get it, and only they
|
||||
* may reveal it.
|
||||
*/
|
||||
user_id: number | string;
|
||||
bot_id: number | string;
|
||||
key: string;
|
||||
value: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Saves a user's personal secret for one bot (an env with paradigm
|
||||
* `user`): what they typed into a secret request's card, or what a bot
|
||||
* captured on a computer for them. Always secret and put on computers, so
|
||||
* it reaches that user's runs by key and is redacted from everything the
|
||||
* bot reads. Replaces the value when they already have that key for the
|
||||
* bot. The user must still reach the bot. Returns the env's id.
|
||||
*/
|
||||
export default async function saveUserSecret({ user_id, bot_id, key, value, description }: Params) {
|
||||
const bot = await grabUserBot({ bot_id, user_id });
|
||||
|
||||
if (!bot?.id) throw new AppError(`You no longer have this bot.`);
|
||||
|
||||
if (!value) throw new AppError(`Enter a value.`);
|
||||
|
||||
const existing = (await grabAllEnvs()).find(
|
||||
(env) =>
|
||||
env.paradigm == "user" &&
|
||||
env.bot_id == Number(bot_id) &&
|
||||
env.user_id == Number(user_id) &&
|
||||
env.key == key.trim(),
|
||||
);
|
||||
|
||||
return await saveEnv({
|
||||
user_id: Number(user_id),
|
||||
env_id: existing?.id,
|
||||
key,
|
||||
value,
|
||||
description: description ?? existing?.description,
|
||||
paradigm: "user",
|
||||
bot_id: Number(bot_id),
|
||||
owner_user_id: Number(user_id),
|
||||
secret: true,
|
||||
expose_to_computers: true,
|
||||
});
|
||||
}
|
||||
@@ -6,6 +6,11 @@ type Params = {
|
||||
* The bot the run is for: its own envs beat everything else.
|
||||
*/
|
||||
bot_id?: number | string | null;
|
||||
/**
|
||||
* The user the run is for: their personal envs for this bot beat the
|
||||
* bot's own.
|
||||
*/
|
||||
user_id?: number | string | null;
|
||||
/**
|
||||
* The workspace the run is in: its envs beat general ones.
|
||||
*/
|
||||
@@ -14,11 +19,12 @@ type Params = {
|
||||
|
||||
/**
|
||||
* The envs an MCP server's or custom tool's templates (`{{env.KEY}}`) read
|
||||
* on one run, one per key: the bot's own beat the workspace's, which beat
|
||||
* on one run, one per key: the user's personal ones for this bot beat the
|
||||
* bot's own, which beat the workspace's, which beat
|
||||
* general ones. App settings (`EnvDict`) never are. Unlike computers' envs,
|
||||
* they don't have to be set on computers: the app fills them in itself.
|
||||
*/
|
||||
export default async function grabRunEnvs({ bot_id, workspace_id }: Params = {}): Promise<DecryptedEnv[]> {
|
||||
export default async function grabRunEnvs({ bot_id, user_id, workspace_id }: Params = {}): Promise<DecryptedEnv[]> {
|
||||
const app_keys = new Set(EnvDict.map((env) => env.key));
|
||||
const envs = (await grabAllEnvs()).filter((env) => !app_keys.has(env.key));
|
||||
const resolved = new Map<string, DecryptedEnv>();
|
||||
@@ -39,5 +45,13 @@ export default async function grabRunEnvs({ bot_id, workspace_id }: Params = {})
|
||||
}
|
||||
}
|
||||
|
||||
if (bot_id && user_id) {
|
||||
for (const env of envs.filter(
|
||||
(e) => e.paradigm == "user" && e.bot_id == Number(bot_id) && e.user_id == Number(user_id),
|
||||
)) {
|
||||
resolved.set(env.key, env);
|
||||
}
|
||||
}
|
||||
|
||||
return [...resolved.values()];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import saveUserSecret from "../envs/save-user-secret";
|
||||
import grabSecretRequest from "./grab-secret-request";
|
||||
|
||||
type Params = {
|
||||
request_id: number | string;
|
||||
user_id: number | string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The user typed the secret a bot asked for into its card: claims the
|
||||
* request once (only a pending one moves, so a double click saves once),
|
||||
* then saves the value as their personal secret for that bot
|
||||
* (`saveUserSecret`). A failed save puts the request back to pending.
|
||||
* The value is never stored on the request.
|
||||
*/
|
||||
export default async function answerSecretRequest({ request_id, user_id, value }: Params) {
|
||||
const request = await grabSecretRequest({ request_id, user_id });
|
||||
const now = Date.now();
|
||||
|
||||
const claimed = await BunSQLite.sql<{ id: number }>({
|
||||
sql: `UPDATE secret_requests SET status = 'saved', decided_at = ?, updated_at = ?
|
||||
WHERE id = ? AND status = 'pending' RETURNING id`,
|
||||
values: [now, now, Number(request.id)],
|
||||
});
|
||||
|
||||
if (!claimed.payload?.[0]) throw new AppError(`This request was already ${request.status}.`);
|
||||
|
||||
try {
|
||||
const env_id = await saveUserSecret({
|
||||
user_id,
|
||||
bot_id: request.bot_id || 0,
|
||||
key: request.key || "",
|
||||
value,
|
||||
description: request.description || "",
|
||||
});
|
||||
|
||||
await BunSQLite.sql({
|
||||
sql: `UPDATE secret_requests SET env_id = ?, updated_at = ? WHERE id = ?`,
|
||||
values: [env_id, Date.now(), Number(request.id)],
|
||||
});
|
||||
|
||||
return request;
|
||||
} catch (error) {
|
||||
await BunSQLite.sql({
|
||||
sql: `UPDATE secret_requests SET status = 'pending', decided_at = NULL, updated_at = ? WHERE id = ?`,
|
||||
values: [Date.now(), Number(request.id)],
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_SECRET_REQUESTS, BunSQLiteTables } from "@/db/types/db";
|
||||
import checkSecretKey from "@/src/utils/check-secret-key";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
bot_id: number | string;
|
||||
session_id: number | string;
|
||||
workspace_id?: number | string | null;
|
||||
key: string;
|
||||
description: string;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Stores a secret a bot asks the user for, pending until they type it
|
||||
* into its card. A request still pending for the same key in the same
|
||||
* conversation is reused (its description and reason updated), so asking
|
||||
* twice shows one card. Returns its id.
|
||||
*/
|
||||
export default async function createSecretRequest({
|
||||
user_id,
|
||||
bot_id,
|
||||
session_id,
|
||||
workspace_id,
|
||||
key,
|
||||
description,
|
||||
reason,
|
||||
}: Params) {
|
||||
const final_key = checkSecretKey({ key });
|
||||
const now = Date.now();
|
||||
|
||||
const pending = (
|
||||
await BunSQLite.sql<{ id: number }>({
|
||||
sql: `UPDATE secret_requests SET description = ?, reason = ?, updated_at = ?
|
||||
WHERE session_id = ? AND user_id = ? AND bot_id = ? AND key = ? AND status = 'pending'
|
||||
RETURNING id`,
|
||||
values: [description, reason, now, Number(session_id), Number(user_id), Number(bot_id), final_key],
|
||||
})
|
||||
).payload?.[0];
|
||||
|
||||
if (pending?.id) return Number(pending.id);
|
||||
|
||||
const res = await BunSQLite.insert<BUN_SQLITE_COBBLE_SECRET_REQUESTS, (typeof BunSQLiteTables)[number]>({
|
||||
table: "secret_requests",
|
||||
data: [
|
||||
{
|
||||
key: final_key,
|
||||
description,
|
||||
reason,
|
||||
status: "pending",
|
||||
user_id: Number(user_id),
|
||||
bot_id: Number(bot_id),
|
||||
session_id: Number(session_id),
|
||||
workspace_id: workspace_id ? Number(workspace_id) : undefined,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const id = Number(res.singleRes?.id || res.postInsertReturn?.insertId);
|
||||
|
||||
if (!res.success || !id) throw new Error(`Couldn't store the request: ${res.msg}`);
|
||||
|
||||
return id;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import grabSecretRequest from "./grab-secret-request";
|
||||
|
||||
type Params = {
|
||||
request_id: number | string;
|
||||
user_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The user declined a secret a bot asked for: nothing is saved.
|
||||
*/
|
||||
export default async function declineSecretRequest({ request_id, user_id }: Params) {
|
||||
const request = await grabSecretRequest({ request_id, user_id });
|
||||
const now = Date.now();
|
||||
|
||||
const claimed = await BunSQLite.sql<{ id: number }>({
|
||||
sql: `UPDATE secret_requests SET status = 'declined', decided_at = ?, updated_at = ?
|
||||
WHERE id = ? AND status = 'pending' RETURNING id`,
|
||||
values: [now, now, Number(request.id)],
|
||||
});
|
||||
|
||||
if (!claimed.payload?.[0]) throw new AppError(`This request was already ${request.status}.`);
|
||||
|
||||
return request;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { SecretRequestStatus } from "@/src/dict/env-dict";
|
||||
import type { SecretRequestView } from "@/src/types";
|
||||
import grabSecretRequest from "./grab-secret-request";
|
||||
|
||||
type Params = {
|
||||
request_id: number | string;
|
||||
user_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A secret request as its chip and card show it: the key, what it's for
|
||||
* and why the bot asks. Never a value: the request never holds one.
|
||||
*/
|
||||
export default async function grabSecretRequestView({ request_id, user_id }: Params): Promise<SecretRequestView> {
|
||||
const row = await grabSecretRequest({ request_id, user_id });
|
||||
|
||||
const bot = (
|
||||
await BunSQLite.sql<{ name: string }>({
|
||||
sql: `SELECT name FROM bots WHERE id = ? LIMIT 1`,
|
||||
values: [row.bot_id || 0],
|
||||
})
|
||||
).payload?.[0];
|
||||
|
||||
return {
|
||||
id: Number(row.id),
|
||||
key: row.key || "",
|
||||
description: row.description || "",
|
||||
reason: row.reason || "",
|
||||
status: (row.status || "pending") as SecretRequestStatus,
|
||||
bot_id: Number(row.bot_id),
|
||||
bot_name: bot?.name || "The bot",
|
||||
env_id: row.env_id ? Number(row.env_id) : null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import type { BUN_SQLITE_COBBLE_SECRET_REQUESTS } from "@/db/types/db";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
|
||||
type Params = {
|
||||
request_id: number | string;
|
||||
/**
|
||||
* Only the user it was asked of reads it.
|
||||
*/
|
||||
user_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A secret request, only for the user it was asked of. Throws otherwise.
|
||||
*/
|
||||
export default async function grabSecretRequest({ request_id, user_id }: Params) {
|
||||
const row = (
|
||||
await BunSQLite.sql<BUN_SQLITE_COBBLE_SECRET_REQUESTS>({
|
||||
sql: `SELECT * FROM secret_requests WHERE id = ? AND user_id = ? LIMIT 1`,
|
||||
values: [Number(request_id), Number(user_id)],
|
||||
})
|
||||
).payload?.[0];
|
||||
|
||||
if (!row?.id) throw new AppError(`That request isn't yours or no longer exists.`);
|
||||
|
||||
return row;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import canRevealEnv from "@/src/utils/rbac/can-reveal-env";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import grabCrewRole from "../access/grab-crew-role";
|
||||
import grabUserBot from "../bots/grab-user-bot";
|
||||
import grabAllEnvs from "../envs/grab-all-envs";
|
||||
|
||||
type Params = {
|
||||
env_id: number | string;
|
||||
user_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* An env a secret ref (`[secret:ID]`) names, with whether this viewer may
|
||||
* reveal it (`canRevealEnv`). Someone else's personal secret reads as
|
||||
* missing: not even its key is shown to anyone else.
|
||||
*/
|
||||
export default async function grabRevealableEnv({ env_id, user_id }: Params) {
|
||||
const env = (await grabAllEnvs()).find((e) => e.id == Number(env_id));
|
||||
|
||||
if (!env || (env.paradigm == "user" && env.user_id != Number(user_id))) {
|
||||
throw new AppError(`That secret isn't yours or no longer exists.`);
|
||||
}
|
||||
|
||||
const role = await grabCrewRole({ user_id });
|
||||
const reaches_bot =
|
||||
env.paradigm == "user" && env.bot_id ? Boolean((await grabUserBot({ bot_id: env.bot_id, user_id }))?.id) : false;
|
||||
|
||||
return { env, can_reveal: canRevealEnv({ env, viewer_id: user_id, role, reaches_bot }) };
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import AppError from "@/src/utils/app-error";
|
||||
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
|
||||
import parseUserTypes from "@/src/utils/parse-user-types";
|
||||
import canRevokeUser from "@/src/utils/rbac/can-revoke-user";
|
||||
import { clearEnvCache } from "../envs/grab-all-envs";
|
||||
import grabUsers from "./grab-users";
|
||||
|
||||
type Params = {
|
||||
@@ -20,8 +21,8 @@ type Params = {
|
||||
|
||||
/**
|
||||
* Deletes a user with everything that's theirs (their conversations,
|
||||
* routines, access, what bots remember about them, user types, password,
|
||||
* media and its files). Bots, workspaces and computers belong to the
|
||||
* routines, access, what bots remember about them, personal secrets, user
|
||||
* types, password, media and its files). Bots, workspaces and computers belong to the
|
||||
* install and stay, pictures they uploaded for them too. Their audit log
|
||||
* entries stay as well. The super admin can't be deleted, and nobody
|
||||
* deletes themselves.
|
||||
@@ -70,9 +71,16 @@ export default async function deleteUser({ actor_user_id, actor_user_types, user
|
||||
/**
|
||||
* These point at the user without a foreign key, so nothing cascades.
|
||||
*/
|
||||
for (const table of ["user_types", "user_passwords", "sso_login_codes"]) {
|
||||
for (const table of ["user_types", "user_passwords", "sso_login_codes", "secret_requests"]) {
|
||||
await BunSQLite.sql({ sql: `DELETE FROM ${table} WHERE user_id = ?`, values: [user.id] });
|
||||
}
|
||||
|
||||
/**
|
||||
* Their personal secrets, explicitly: foreign keys are off outside the
|
||||
* server process.
|
||||
*/
|
||||
await BunSQLite.sql({ sql: `DELETE FROM envs WHERE paradigm = 'user' AND user_id = ?`, values: [user.id] });
|
||||
clearEnvCache();
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user