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>
251 lines
8.2 KiB
TypeScript
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,
|
|
),
|
|
};
|
|
}
|