evOo-local/apps/api/src/ai-consent.ts
Cursor Agent 44d1bc7277 fix(api): redact memory provider details in aiConsent/status for members
Non-owner shared-space members could read the space memory provider id
and internal endpoint origin via aiConsent/status, bypassing the owner-only
policy that draft PR #46 applies to memory/providerConfig.

Owners still see full provider disclosure; members get a generic Space
memory recipient when a provider is configured, without loading settings.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-09-22 07:10:42 +00:00

251 lines
8.2 KiB
TypeScript

import { createHash } from "node:crypto";
import { ORPCError } from "@orpc/server";
import {
aiRecipient,
cloudAgentsEnabled,
parseModelSecret,
selectConfiguredModel,
toStringRecord,
} from "@rakazo/adapters";
import type { Actor, AiConsentQuery, AiConsentStatus, AiRecipient } from "@rakazo/contracts";
import { AI_DATA_DISCLOSURES, AI_DISCLOSURE_VERSION, AI_PRIVACY_URL } from "@rakazo/contracts";
import {
findDefaultModelCredential,
findDefaultVoiceCredential,
findModelCredential,
} from "@rakazo/db";
import { isSpaceOwner } from "./memory-provider-config.js";
import type { RouterDeps } from "./router.js";
import { resolveThreadTarget } from "./thread-target.js";
function genericSpaceMemoryRecipient(): Omit<AiRecipient, "allowed"> {
return {
key: createHash("sha256").update(JSON.stringify(["memory", "space-memory"])).digest("hex"),
name: "Space memory",
use: "memory",
detail: AI_DATA_DISCLOSURES.memory,
};
}
export async function aiConsentStatus(
deps: RouterDeps,
actor: Actor,
query: AiConsentQuery = {},
): Promise<AiConsentStatus> {
const uses = query.uses ?? ["model", "memory", "voice"];
const modelsEnabled = uses.includes("model") && deps.env.agentRuntime !== "scripted";
const target =
query.botId || query.groupId ? await resolveThreadTarget(deps.prisma, actor, query) : null;
const botIds = target
? target.kind === "bot"
? [target.botId]
: target.memberBotIds
: undefined;
const includesMemory = uses.includes("memory");
const [preferences, defaultCredential, bots, voices, memoryOwner, settings, consents] =
await Promise.all([
modelsEnabled
? deps.prisma.spaceModelPreference.findMany({
where: { userId: actor.userId, spaceId: actor.spaceId },
include: { credential: true },
})
: [],
modelsEnabled ? findDefaultModelCredential(deps.prisma, actor) : null,
modelsEnabled
? deps.prisma.bot.findMany({
where: {
userId: actor.userId,
spaceId: actor.spaceId,
archivedAt: null,
...(botIds ? { id: { in: botIds } } : {}),
},
select: { modelProvider: true, modelId: true, thinkingLevel: true },
})
: [],
uses.includes("voice")
? query.uses
? findDefaultVoiceCredential(deps.prisma, actor).then((credential) =>
credential ? [{ credential }] : [],
)
: deps.prisma.spaceVoicePreference.findMany({
where: { userId: actor.userId, spaceId: actor.spaceId },
include: { credential: true },
})
: [],
includesMemory ? isSpaceOwner(deps.prisma, actor) : false,
modelsEnabled
? deps.prisma.deploymentSettings.findUnique({ where: { id: "default" } })
: null,
deps.prisma.aiDataConsent.findMany({
where: { userId: actor.userId, spaceId: actor.spaceId, version: AI_DISCLOSURE_VERSION },
}),
]);
const allowed = new Set(consents.map((row) => row.recipientKey));
const recipients = new Map<string, AiRecipient>();
const add = (recipient: ReturnType<typeof aiRecipient>) => {
if (recipient)
recipients.set(recipient.key, { ...recipient, allowed: allowed.has(recipient.key) });
};
if (modelsEnabled) {
const deployment = deps.env.deploymentModelKey
? { provider: deps.env.defaultProvider, model: deps.env.defaultModel }
: null;
const selected = await Promise.all(
(target ? bots : [null, ...bots]).map(async (bot) => {
const overrideCredential =
bot?.modelProvider && bot.modelId
? await findModelCredential(deps.prisma, actor, bot.modelProvider, bot.modelId)
: null;
return selectConfiguredModel({
bot,
overrideCredential,
defaultCredential,
settings,
deployment,
});
}),
);
// Connected models remain reachable by helpers during a targeted bot run.
for (const preference of preferences)
selected.push({
provider: preference.credential.provider,
id: preference.modelId ?? "",
credential: {
...preference.credential,
isDefault: preference.isDefault,
defaultModel: preference.modelId,
},
thinkingLevel: null,
});
if (deps.env.teamChatJudgeProvider && deps.env.teamChatJudgeModel) {
selected.push({
provider: deps.env.teamChatJudgeProvider,
id: deps.env.teamChatJudgeModel,
credential: await findModelCredential(
deps.prisma,
actor,
deps.env.teamChatJudgeProvider,
deps.env.teamChatJudgeModel,
),
thinkingLevel: null,
});
}
const models = [
...new Map(
selected
.filter((model) => model.provider)
.map((model) => [
JSON.stringify([model.provider, model.id, model.credential?.secretId]),
model,
]),
).values(),
];
const secrets = models.length
? await deps.prisma.secret.findMany({
where: {
userId: actor.userId,
spaceId: null,
id: {
in: models.flatMap((model) => (model.credential ? [model.credential.secretId] : [])),
},
},
})
: [];
const baseUrls = new Map(
secrets.map((secret) => {
const parsed = parseModelSecret(deps.secrets.load(secret.ciphertext, secret.id));
return [secret.id, parsed.kind === "openai_compatible" ? parsed.baseUrl : undefined];
}),
);
for (const model of models) {
add(
aiRecipient({
provider: model.provider!,
modelId: model.id!,
baseUrl: model.credential ? baseUrls.get(model.credential.secretId) : undefined,
use: "model",
}),
);
}
}
for (const voice of voices)
add(aiRecipient({ provider: voice.credential.provider, use: "voice" }));
if (includesMemory) {
if (memoryOwner) {
const memory = await deps.prisma.spaceMemoryConfig.findUnique({
where: { spaceId: actor.spaceId },
});
if (memory)
add(
aiRecipient({
provider: memory.provider,
use: "memory",
baseUrl: toStringRecord(memory.settings).baseUrl,
}),
);
} else if (
(await deps.prisma.spaceMemoryConfig.count({ where: { spaceId: actor.spaceId } })) > 0
) {
add(genericSpaceMemoryRecipient());
}
}
if (
uses.includes("model") &&
deps.cloudAgent &&
cloudAgentsEnabled(deps.cloudAgent, actor.spaceId) &&
!deps.cloudAgent.provider.describe().capabilities.offline
) {
add(aiRecipient({ provider: deps.cloudAgent.provider.describe().id, use: "model" }));
}
return {
scope: createHash("sha256")
.update(JSON.stringify([actor.userId, actor.spaceId]))
.digest("hex"),
version: AI_DISCLOSURE_VERSION,
privacyUrl: deps.env.privacyPolicyUrl ?? AI_PRIVACY_URL,
recipients: [...recipients.values()],
};
}
export async function allowAiConsent(
deps: RouterDeps,
actor: Actor,
input: { scope: string; version: string; keys: string[] },
) {
const current = await aiConsentStatus(deps, actor);
const known = new Set(current.recipients.map((recipient) => recipient.key));
if (
input.scope !== current.scope ||
input.version !== current.version ||
input.keys.some((key) => !known.has(key))
) {
throw new ORPCError("CONFLICT", { message: "AI data sharing changed. Review it again." });
}
await deps.prisma.$transaction(
input.keys.map((recipientKey) =>
deps.prisma.aiDataConsent.upsert({
where: {
userId_spaceId_recipientKey: {
userId: actor.userId,
spaceId: actor.spaceId,
recipientKey,
},
},
create: {
userId: actor.userId,
spaceId: actor.spaceId,
recipientKey,
version: current.version,
},
update: { version: current.version, grantedAt: new Date() },
}),
),
);
return {
...current,
recipients: current.recipients.map((recipient) =>
input.keys.includes(recipient.key) ? { ...recipient, allowed: true } : recipient,
),
};
}