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:
2026-10-25 16:51:32 +01:00
co-authored by Claude Opus 5.5
parent 83ff79b9c3
commit 69988e08fc
78 changed files with 2472 additions and 52 deletions
+7 -1
View File
@@ -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) {
+41 -13
View File
@@ -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 }) };
}
+11 -3
View File
@@ -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;
}
+23
View File
@@ -0,0 +1,23 @@
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
type Params = {
env_id: number;
};
/**
* A secret's value, for a Reveal or Copy click. Throws why when the
* viewer may not see it.
*/
export default async function revealSecret({ env_id }: Params): Promise<string> {
const res = await fetchApi<{ env_id: number }, APIResponseObject<{ value: string }>>(
`/api/admin/secrets/reveal-secret`,
{ method: "POST", body: { env_id } },
);
if (!res?.success || typeof res.singleRes?.value != "string") {
throw new Error(res?.msg || `Couldn't read the secret.`);
}
return res.singleRes.value;
}