forked from EduCraft/curriculum-project-hub
440 lines
16 KiB
TypeScript
440 lines
16 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import type { Prisma, PrismaClient } from "@prisma/client";
|
|
import {
|
|
fingerprintFeishuAppId,
|
|
validateFeishuApplicationCredential,
|
|
type FeishuApplicationCredentialInput,
|
|
} from "../connections/feishuApplicationConnections.js";
|
|
import {
|
|
decryptStoredProviderCredential,
|
|
ProviderConnectionService,
|
|
type ProviderCredentialInput,
|
|
} from "../connections/providerConnections.js";
|
|
import { probeFeishuApplication, type FeishuReadinessProbe } from "../connections/feishuReadiness.js";
|
|
import { probeOpenRouterCredential, type ProviderReadinessProbe } from "../connections/providerReadiness.js";
|
|
import { deterministicFeishuUserId, scopedFeishuPrincipalId } from "../feishu/identityNamespace.js";
|
|
import { LocalSecretEnvelope } from "../security/secretEnvelope.js";
|
|
|
|
export interface AlphaSiloBootstrapInput {
|
|
readonly organization: { readonly id: string; readonly slug: string; readonly name: string };
|
|
readonly owner: { readonly openId: string; readonly displayName: string; readonly unionId?: string };
|
|
readonly feishu: FeishuApplicationCredentialInput;
|
|
readonly provider: ProviderCredentialInput & { readonly providerId: string };
|
|
readonly teams?: readonly {
|
|
readonly slug: string;
|
|
readonly name: string;
|
|
readonly description?: string;
|
|
}[];
|
|
}
|
|
|
|
export interface AlphaSiloBootstrapResult {
|
|
readonly organizationId: string;
|
|
readonly ownerUserId: string;
|
|
readonly feishuConnectionId: string;
|
|
readonly initialized: boolean;
|
|
readonly providerConfigured: boolean;
|
|
}
|
|
|
|
/**
|
|
* Idempotently bootstrap the one Organization allowed in an Alpha Silo.
|
|
* Secrets are accepted only in-memory and persisted as authenticated envelopes.
|
|
*/
|
|
export async function bootstrapAlphaSilo(
|
|
prisma: PrismaClient,
|
|
secrets: LocalSecretEnvelope,
|
|
input: AlphaSiloBootstrapInput,
|
|
probes: {
|
|
readonly feishu?: FeishuReadinessProbe;
|
|
readonly provider?: ProviderReadinessProbe;
|
|
} = {},
|
|
): Promise<AlphaSiloBootstrapResult> {
|
|
const normalized = validateInput(input);
|
|
await removePristineLegacyMigrationOrganization(prisma);
|
|
const existing = await loadExistingSilo(prisma, normalized.organization.id, normalized.owner.openId);
|
|
let initialized = false;
|
|
let ownerUserId: string;
|
|
let feishuConnectionId: string;
|
|
|
|
if (existing === null) {
|
|
await (probes.feishu ?? probeFeishuApplication)(normalized.feishu);
|
|
const initializedSilo = await initializeSilo(prisma, secrets, normalized);
|
|
initialized = true;
|
|
ownerUserId = initializedSilo.ownerUserId;
|
|
feishuConnectionId = initializedSilo.feishuConnectionId;
|
|
} else {
|
|
ownerUserId = existing.ownerUserId;
|
|
feishuConnectionId = existing.feishuConnectionId;
|
|
const expectedFingerprint = fingerprintFeishuAppId(normalized.feishu.appId);
|
|
if (existing.appIdentityFingerprint !== expectedFingerprint) {
|
|
throw new Error("bootstrap Feishu app identity does not match the existing Silo");
|
|
}
|
|
}
|
|
|
|
const providerService = new ProviderConnectionService(
|
|
prisma,
|
|
secrets,
|
|
probes.provider ?? probeOpenRouterCredential,
|
|
);
|
|
const existingProviders = await providerService.list(normalized.organization.id);
|
|
const existingProvider = existingProviders.find((connection) => connection.providerId === normalized.provider.providerId);
|
|
if (existingProvider === undefined) {
|
|
await providerService.rotateByok({
|
|
organizationId: normalized.organization.id,
|
|
actorUserId: ownerUserId,
|
|
providerId: normalized.provider.providerId,
|
|
baseUrl: normalized.provider.baseUrl,
|
|
authToken: normalized.provider.authToken,
|
|
...(normalized.provider.anthropicApiKey !== undefined
|
|
? { anthropicApiKey: normalized.provider.anthropicApiKey }
|
|
: {}),
|
|
});
|
|
} else if (existingProvider.status !== "ACTIVE" || existingProvider.mode !== "BYOK") {
|
|
throw new Error(`bootstrap provider ${normalized.provider.providerId} exists but is not ACTIVE BYOK`);
|
|
} else {
|
|
const stored = await loadExistingProviderCredential(
|
|
prisma,
|
|
secrets,
|
|
normalized.organization.id,
|
|
normalized.provider.providerId,
|
|
);
|
|
if (stored.baseUrl !== normalized.provider.baseUrl || stored.authToken !== normalized.provider.authToken ||
|
|
stored.anthropicApiKey !== (normalized.provider.anthropicApiKey ?? "")) {
|
|
throw new Error(`bootstrap provider ${normalized.provider.providerId} credential does not match existing Silo`);
|
|
}
|
|
}
|
|
|
|
await ensureBootstrapTeams(prisma, normalized.organization.id, ownerUserId, normalized.teams ?? []);
|
|
|
|
return {
|
|
organizationId: normalized.organization.id,
|
|
ownerUserId,
|
|
feishuConnectionId,
|
|
initialized,
|
|
providerConfigured: true,
|
|
};
|
|
}
|
|
|
|
const LEGACY_MIGRATION_ORGANIZATION = {
|
|
id: "org_default",
|
|
slug: "legacy-default",
|
|
name: "Legacy Default Organization",
|
|
inboxId: "folder_inbox_2b99350e0db97ad0cbcb55c20ee8bafa",
|
|
} as const;
|
|
|
|
/**
|
|
* A fresh database still receives the compatibility Organization inserted by
|
|
* the tenant-root migration, plus its later default settings and Inbox. An
|
|
* Alpha Silo bootstrap may replace only that exact, otherwise-unused scaffold.
|
|
* Any tenant data or shape drift remains a hard manual-repair error.
|
|
*/
|
|
async function removePristineLegacyMigrationOrganization(prisma: PrismaClient): Promise<void> {
|
|
await prisma.$transaction(async (tx) => {
|
|
const organization = await tx.organization.findUnique({
|
|
where: { id: LEGACY_MIGRATION_ORGANIZATION.id },
|
|
include: {
|
|
memberships: { take: 1, select: { id: true } },
|
|
projects: { take: 1, select: { id: true } },
|
|
teams: { take: 1, select: { id: true } },
|
|
externalDirectoryConnections: { take: 1, select: { id: true } },
|
|
providerConnections: { take: 1, select: { id: true } },
|
|
feishuApplicationConnection: { select: { id: true } },
|
|
auditEntries: { take: 1, select: { id: true } },
|
|
projectSettings: true,
|
|
folders: {
|
|
take: 2,
|
|
select: { id: true, parentId: true, name: true, sortKey: true, archivedAt: true },
|
|
},
|
|
},
|
|
});
|
|
if (organization === null) return;
|
|
const pristine =
|
|
organization.slug === LEGACY_MIGRATION_ORGANIZATION.slug &&
|
|
organization.name === LEGACY_MIGRATION_ORGANIZATION.name &&
|
|
organization.status === "ACTIVE" &&
|
|
organization.memberships.length === 0 &&
|
|
organization.projects.length === 0 &&
|
|
organization.teams.length === 0 &&
|
|
organization.externalDirectoryConnections.length === 0 &&
|
|
organization.providerConnections.length === 0 &&
|
|
organization.feishuApplicationConnection === null &&
|
|
organization.auditEntries.length === 0 &&
|
|
organization.projectSettings?.membersCanCreateProjects === true &&
|
|
organization.folders.length === 1 &&
|
|
organization.folders[0]?.id === LEGACY_MIGRATION_ORGANIZATION.inboxId &&
|
|
organization.folders[0].parentId === null &&
|
|
organization.folders[0].name === "Inbox" &&
|
|
organization.folders[0].sortKey === "000000" &&
|
|
organization.folders[0].archivedAt === null;
|
|
if (pristine) {
|
|
await tx.organization.delete({ where: { id: organization.id } });
|
|
}
|
|
});
|
|
}
|
|
|
|
async function ensureBootstrapTeams(
|
|
prisma: PrismaClient,
|
|
organizationId: string,
|
|
ownerUserId: string,
|
|
teams: NonNullable<AlphaSiloBootstrapInput["teams"]>,
|
|
): Promise<void> {
|
|
await prisma.$transaction(async (tx) => {
|
|
for (const team of teams) {
|
|
const existing = await tx.team.findFirst({
|
|
where: { organizationId, slug: team.slug, archivedAt: null },
|
|
select: { id: true, name: true, description: true },
|
|
});
|
|
if (existing !== null && (existing.name !== team.name || existing.description !== (team.description ?? null))) {
|
|
throw new Error(`bootstrap Team ${team.slug} does not match the existing Silo`);
|
|
}
|
|
const teamId = existing?.id ?? (await tx.team.create({
|
|
data: {
|
|
organizationId,
|
|
slug: team.slug,
|
|
name: team.name,
|
|
...(team.description !== undefined ? { description: team.description } : {}),
|
|
},
|
|
select: { id: true },
|
|
})).id;
|
|
const membership = await tx.teamMembership.findFirst({
|
|
where: { teamId, userId: ownerUserId, revokedAt: null },
|
|
select: { id: true },
|
|
});
|
|
if (membership === null) await tx.teamMembership.create({ data: { teamId, userId: ownerUserId } });
|
|
}
|
|
});
|
|
}
|
|
|
|
async function initializeSilo(
|
|
prisma: PrismaClient,
|
|
secrets: LocalSecretEnvelope,
|
|
input: AlphaSiloBootstrapInput,
|
|
): Promise<{ readonly ownerUserId: string; readonly feishuConnectionId: string }> {
|
|
const connectionId = randomUUID();
|
|
const secretVersionId = randomUUID();
|
|
const identityId = randomUUID();
|
|
const ownerUserId = deterministicFeishuUserId(connectionId, input.owner.openId);
|
|
const appIdentityFingerprint = fingerprintFeishuAppId(input.feishu.appId);
|
|
const payload = validateFeishuApplicationCredential(input.feishu);
|
|
const envelope = secrets.encryptJson({
|
|
purpose: "feishu-application",
|
|
organizationId: input.organization.id,
|
|
connectionId,
|
|
secretVersionId,
|
|
}, payload);
|
|
|
|
await prisma.$transaction(async (tx) => {
|
|
const count = await tx.organization.count();
|
|
if (count !== 0) throw new Error(`Silo bootstrap requires an empty Organization set; found ${count}`);
|
|
await tx.organization.create({ data: { ...input.organization } });
|
|
await tx.organizationAgentRole.createMany({
|
|
data: [
|
|
{
|
|
organizationId: input.organization.id,
|
|
roleId: "draft",
|
|
label: "草稿",
|
|
sortOrder: 10,
|
|
},
|
|
{
|
|
organizationId: input.organization.id,
|
|
roleId: "review",
|
|
label: "审校",
|
|
sortOrder: 20,
|
|
},
|
|
],
|
|
});
|
|
await tx.organizationProjectSettings.create({
|
|
data: { organizationId: input.organization.id, membersCanCreateProjects: true },
|
|
});
|
|
await tx.folder.create({
|
|
data: {
|
|
organizationId: input.organization.id,
|
|
name: "Inbox",
|
|
sortKey: "000000",
|
|
},
|
|
});
|
|
await tx.user.create({
|
|
data: {
|
|
id: ownerUserId,
|
|
feishuOpenId: scopedFeishuPrincipalId("USER", connectionId, input.owner.openId),
|
|
displayName: input.owner.displayName,
|
|
},
|
|
});
|
|
await tx.organizationFeishuApplicationConnection.create({
|
|
data: {
|
|
id: connectionId,
|
|
organizationId: input.organization.id,
|
|
appIdentityFingerprint,
|
|
status: "DRAFT",
|
|
},
|
|
});
|
|
await tx.feishuUserIdentity.create({
|
|
data: {
|
|
id: identityId,
|
|
connectionId,
|
|
userId: ownerUserId,
|
|
openId: input.owner.openId,
|
|
...(input.owner.unionId !== undefined ? { unionId: input.owner.unionId } : {}),
|
|
},
|
|
});
|
|
await tx.organizationMembership.create({
|
|
data: { organizationId: input.organization.id, userId: ownerUserId, role: "OWNER" },
|
|
});
|
|
for (const team of input.teams ?? []) {
|
|
await tx.team.create({
|
|
data: {
|
|
organizationId: input.organization.id,
|
|
slug: team.slug,
|
|
name: team.name,
|
|
...(team.description !== undefined ? { description: team.description } : {}),
|
|
memberships: { create: { userId: ownerUserId } },
|
|
},
|
|
});
|
|
}
|
|
await tx.feishuApplicationCredentialVersion.create({
|
|
data: {
|
|
id: secretVersionId,
|
|
connectionId,
|
|
version: 1,
|
|
envelopeVersion: envelope.version,
|
|
keyId: envelope.keyId,
|
|
envelope: envelope as unknown as Prisma.InputJsonValue,
|
|
createdByUserId: ownerUserId,
|
|
},
|
|
});
|
|
await tx.organizationFeishuApplicationConnection.update({
|
|
where: { id: connectionId },
|
|
data: {
|
|
status: "ACTIVE",
|
|
activeSecretVersionId: secretVersionId,
|
|
activatedAt: new Date(),
|
|
},
|
|
});
|
|
await tx.auditEntry.create({
|
|
data: {
|
|
organizationId: input.organization.id,
|
|
actorUserId: ownerUserId,
|
|
action: "alpha_silo.bootstrapped",
|
|
metadata: {
|
|
connectionId,
|
|
ownerIdentityId: identityId,
|
|
envelopeVersion: envelope.version,
|
|
keyId: envelope.keyId,
|
|
},
|
|
},
|
|
});
|
|
});
|
|
return { ownerUserId, feishuConnectionId: connectionId };
|
|
}
|
|
|
|
async function loadExistingSilo(
|
|
prisma: PrismaClient,
|
|
organizationId: string,
|
|
ownerOpenId: string,
|
|
): Promise<{
|
|
readonly ownerUserId: string;
|
|
readonly feishuConnectionId: string;
|
|
readonly appIdentityFingerprint: string;
|
|
} | null> {
|
|
const organizations = await prisma.organization.findMany({
|
|
take: 2,
|
|
include: {
|
|
memberships: {
|
|
where: { role: "OWNER", revokedAt: null },
|
|
take: 2,
|
|
include: { user: { include: { feishuIdentities: true } } },
|
|
},
|
|
feishuApplicationConnection: true,
|
|
},
|
|
});
|
|
if (organizations.length === 0) return null;
|
|
if (organizations.length !== 1 || organizations[0]!.id !== organizationId) {
|
|
throw new Error("bootstrap database is not the configured single-Organization Silo");
|
|
}
|
|
const organization = organizations[0]!;
|
|
if (organization.status !== "ACTIVE" || organization.memberships.length !== 1 ||
|
|
organization.feishuApplicationConnection?.status !== "ACTIVE") {
|
|
throw new Error("existing Silo is incomplete or inactive; manual repair is required");
|
|
}
|
|
const ownerIdentity = organization.memberships[0]!.user.feishuIdentities.find(
|
|
(identity) => identity.connectionId === organization.feishuApplicationConnection!.id,
|
|
);
|
|
if (ownerIdentity?.openId !== ownerOpenId) {
|
|
throw new Error("bootstrap owner identity does not match the existing Silo");
|
|
}
|
|
return {
|
|
ownerUserId: organization.memberships[0]!.userId,
|
|
feishuConnectionId: organization.feishuApplicationConnection.id,
|
|
appIdentityFingerprint: organization.feishuApplicationConnection.appIdentityFingerprint,
|
|
};
|
|
}
|
|
|
|
async function loadExistingProviderCredential(
|
|
prisma: PrismaClient,
|
|
secrets: LocalSecretEnvelope,
|
|
organizationId: string,
|
|
providerId: string,
|
|
): Promise<{ readonly baseUrl: string; readonly authToken: string; readonly anthropicApiKey: string }> {
|
|
const connection = await prisma.organizationProviderConnection.findUnique({
|
|
where: { organizationId_providerId: { organizationId, providerId } },
|
|
include: { activeSecretVersion: true },
|
|
});
|
|
const version = connection?.activeSecretVersion;
|
|
if (connection === null || connection === undefined || version === null || version === undefined ||
|
|
connection.status !== "ACTIVE" || version.retiredAt !== null) {
|
|
throw new Error(`active provider connection not found: ${providerId}`);
|
|
}
|
|
return decryptStoredProviderCredential(secrets, {
|
|
organizationId,
|
|
connectionId: connection.id,
|
|
providerId,
|
|
secretVersionId: version.id,
|
|
envelopeVersion: version.envelopeVersion,
|
|
keyId: version.keyId,
|
|
envelope: version.envelope,
|
|
});
|
|
}
|
|
|
|
function validateInput(input: AlphaSiloBootstrapInput): AlphaSiloBootstrapInput {
|
|
const required = (value: string, label: string): string => {
|
|
const normalized = value.trim();
|
|
if (normalized === "") throw new Error(`${label} is required`);
|
|
return normalized;
|
|
};
|
|
const slug = required(input.organization.slug, "Organization slug");
|
|
if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(slug)) {
|
|
throw new Error("Organization slug must be lowercase alphanumeric with optional hyphens");
|
|
}
|
|
if (input.provider.providerId.trim() !== "openrouter") {
|
|
throw new Error("Alpha Silo currently requires providerId=openrouter");
|
|
}
|
|
return {
|
|
organization: {
|
|
id: required(input.organization.id, "Organization id"),
|
|
slug,
|
|
name: required(input.organization.name, "Organization name"),
|
|
},
|
|
owner: {
|
|
openId: required(input.owner.openId, "Owner Feishu openId"),
|
|
displayName: required(input.owner.displayName, "Owner displayName"),
|
|
...(input.owner.unionId !== undefined ? { unionId: required(input.owner.unionId, "Owner unionId") } : {}),
|
|
},
|
|
feishu: input.feishu,
|
|
provider: input.provider,
|
|
...(input.teams !== undefined
|
|
? {
|
|
teams: input.teams.map((team) => {
|
|
const teamSlug = required(team.slug, "Team slug");
|
|
if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(teamSlug)) {
|
|
throw new Error(`invalid Team slug: ${teamSlug}`);
|
|
}
|
|
return {
|
|
slug: teamSlug,
|
|
name: required(team.name, `Team ${teamSlug} name`),
|
|
...(team.description !== undefined ? { description: team.description.trim() } : {}),
|
|
};
|
|
}),
|
|
}
|
|
: {}),
|
|
};
|
|
}
|