evOo-local/apps/api/src/memory-provider-config.ts
cynarAI 04528a93e3 fix(api): restore getMemoryProviderConfig export (#68)
Commit 928d9ad refactored requireSpaceOwner into isSpaceOwner but
accidentally dropped getMemoryProviderConfig, breaking API boot while
router.ts still imported it. Restore the owner-gated read helper.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2026-09-22 09:04:43 +00:00

194 lines
6.7 KiB
TypeScript

import { ORPCError } from "@orpc/server";
import type { SecretStore } from "@rakazo/adapter-kit";
import {
classifyMemoryProviderSettings,
MemoryProviderDeploymentOwnerRequiredError,
memoryProviderRequiresDeploymentOwner,
prepareMemoryProviderConnection,
toStringRecord,
} from "@rakazo/adapters";
import type { Actor } from "@rakazo/contracts";
import { findSpaceMemoryConfig, Prisma, type PrismaClient } from "@rakazo/db";
import { withSerializableRetry } from "./serializable-retry.js";
export interface MemoryProviderConfigDeps {
prisma: PrismaClient;
secrets: Pick<SecretStore, "put">;
/** Test seam: override DNS/trust classification without probing. */
classifySettings?: (
provider: string,
settings: Record<string, string>,
) => Promise<Record<string, string>>;
/** Test seam: override prepare/probe. */
prepareConnection?: (
input: Parameters<typeof prepareMemoryProviderConnection>[0],
) => ReturnType<typeof prepareMemoryProviderConnection>;
}
export async function isSpaceOwner(prisma: PrismaClient, actor: Actor): Promise<boolean> {
const member = await prisma.spaceMember.findUnique({
where: { spaceId_userId: { spaceId: actor.spaceId, userId: actor.userId } },
select: { role: true },
});
const roles = member?.role.split(",").map((role) => role.trim());
return roles?.includes("owner") ?? false;
}
async function requireSpaceOwner(prisma: PrismaClient, actor: Actor): Promise<void> {
if (!(await isSpaceOwner(prisma, actor))) throw new ORPCError("FORBIDDEN");
}
export async function persistMemoryProviderConfig(
deps: MemoryProviderConfigDeps,
actor: Actor,
input: {
provider: string;
settings: Record<string, string>;
credentials: Record<string, string>;
defaultMemoryScope: "isolated" | "shared";
},
) {
await requireSpaceOwner(deps.prisma, actor);
let prepared: Awaited<ReturnType<typeof prepareMemoryProviderConnection>>;
try {
// Fast-path known private endpoints before probing.
if (
memoryProviderRequiresDeploymentOwner(input.provider, input.settings) &&
!actor.isDeploymentOwner
) {
throw new ORPCError("FORBIDDEN");
}
const classify = deps.classifySettings ?? classifyMemoryProviderSettings;
const prepare = deps.prepareConnection ?? prepareMemoryProviderConnection;
// Classify DNS/trust before any credentialed probe so LAN endpoints stay owner-gated.
const classifiedSettings = await classify(input.provider, input.settings);
if (
memoryProviderRequiresDeploymentOwner(input.provider, classifiedSettings) &&
!actor.isDeploymentOwner
) {
throw new ORPCError("FORBIDDEN");
}
prepared = await prepare({
...input,
settings: classifiedSettings,
allowPrivateEndpoint: actor.isDeploymentOwner,
});
// Defense in depth if prepare reclassified further.
if (
memoryProviderRequiresDeploymentOwner(prepared.provider, prepared.settings) &&
!actor.isDeploymentOwner
) {
throw new ORPCError("FORBIDDEN");
}
} catch (error) {
if (error instanceof ORPCError) throw error;
if (error instanceof MemoryProviderDeploymentOwnerRequiredError) {
throw new ORPCError("FORBIDDEN");
}
throw new ORPCError("BAD_REQUEST", {
message: error instanceof Error ? error.message : "Memory provider connection failed",
});
}
const stored = await deps.secrets.put(JSON.stringify(prepared.credentials), {
operationId: "memory-provider-config",
traceId: "memory-provider-config",
spaceId: actor.spaceId,
userId: actor.userId,
signal: new AbortController().signal,
});
const config = await withSerializableRetry(() =>
deps.prisma.$transaction(
async (tx) => {
const existing = await findSpaceMemoryConfig(tx, actor.spaceId);
const secret = await tx.secret.create({
data: {
id: stored.id,
userId: actor.userId,
spaceId: actor.spaceId,
kind: "memory-provider",
ciphertext: stored.ciphertext,
},
});
const updated = await tx.spaceMemoryConfig.upsert({
where: { spaceId: actor.spaceId },
create: {
spaceId: actor.spaceId,
userId: actor.userId,
provider: prepared.provider,
settings: prepared.settings,
secretId: secret.id,
defaultMemoryScope: input.defaultMemoryScope,
},
update: {
userId: actor.userId,
provider: prepared.provider,
settings: prepared.settings,
secretId: secret.id,
defaultMemoryScope: input.defaultMemoryScope,
},
});
if (existing && existing.secretId !== secret.id) {
await tx.secret.deleteMany({ where: { id: existing.secretId } });
}
return updated;
},
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
),
);
return serializeSpaceMemoryConfig(config);
}
export async function getMemoryProviderConfig(
deps: MemoryProviderConfigDeps,
actor: Actor,
): Promise<ReturnType<typeof serializeSpaceMemoryConfig> | null> {
await requireSpaceOwner(deps.prisma, actor);
const config = await findSpaceMemoryConfig(deps.prisma, actor.spaceId);
return config ? serializeSpaceMemoryConfig(config) : null;
}
export async function updateMemoryProviderDefaultScope(
deps: MemoryProviderConfigDeps,
actor: Actor,
defaultMemoryScope: "isolated" | "shared",
) {
await requireSpaceOwner(deps.prisma, actor);
const existing = await findSpaceMemoryConfig(deps.prisma, actor.spaceId);
if (!existing) throw new ORPCError("NOT_FOUND");
const updated = await deps.prisma.spaceMemoryConfig.update({
where: { id: existing.id },
data: { defaultMemoryScope },
});
return serializeSpaceMemoryConfig(updated);
}
export function serializeSpaceMemoryConfig(config: {
provider: string;
settings: unknown;
defaultMemoryScope: string;
updatedAt: Date;
}) {
return {
provider: config.provider,
settings: toStringRecord(config.settings),
defaultMemoryScope: config.defaultMemoryScope as "isolated" | "shared",
updatedAt: config.updatedAt.toISOString(),
};
}
export async function disconnectMemoryProvider(deps: MemoryProviderConfigDeps, actor: Actor) {
await requireSpaceOwner(deps.prisma, actor);
await withSerializableRetry(() =>
deps.prisma.$transaction(
async (tx) => {
const existing = await findSpaceMemoryConfig(tx, actor.spaceId);
if (!existing) return;
await tx.spaceMemoryConfig.delete({ where: { id: existing.id } });
await tx.secret.deleteMany({ where: { id: existing.secretId } });
},
{ isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
),
);
return { ok: true as const };
}