import { createHash } from "node:crypto"; import type { Prisma, PrismaClient } from "@prisma/client"; import { lockActiveOrganization } from "../org/status.js"; export type FeishuPrincipalKind = "USER" | "CHAT" | "DEPARTMENT" | "USER_GROUP" | "EVENT" | "CALLBACK"; export interface ScopedFeishuIdentity { readonly identityId: string; readonly connectionId: string; readonly organizationId: string; readonly userId: string; readonly openId: string; readonly unionId: string | null; readonly principalId: string; readonly displayName: string; readonly avatarUrl: string | null; } export function scopedFeishuPrincipalId( kind: FeishuPrincipalKind, connectionId: string, providerLocalId: string, ): string { const normalizedConnectionId = nonEmpty(connectionId, "Feishu connectionId"); const normalizedProviderLocalId = nonEmpty(providerLocalId, `Feishu ${kind} identifier`); const digest = createHash("sha256") .update("cph-feishu-principal\0") .update(kind, "utf8") .update("\0") .update(normalizedConnectionId, "utf8") .update("\0") .update(normalizedProviderLocalId, "utf8") .digest("base64url"); return `feishu:${kind.toLowerCase()}:${normalizedConnectionId}:${digest}`; } export async function upsertScopedFeishuIdentity( prisma: PrismaClient, input: { readonly connectionId: string; readonly openId: string; readonly unionId?: string | undefined; readonly displayName: string; readonly avatarUrl?: string | undefined; }, ): Promise { return prisma.$transaction((tx) => upsertScopedFeishuIdentityInTransaction(tx, input)); } export async function upsertScopedFeishuIdentityInTransaction( prisma: Prisma.TransactionClient, input: { readonly connectionId: string; readonly expectedOrganizationId?: string | undefined; readonly openId: string; readonly unionId?: string | undefined; readonly displayName: string; readonly avatarUrl?: string | undefined; }, ): Promise { const connectionId = nonEmpty(input.connectionId, "Feishu connectionId"); const openId = nonEmpty(input.openId, "Feishu openId"); const displayName = nonEmpty(input.displayName, "Feishu displayName"); const unionId = optionalNonEmpty(input.unionId, "Feishu unionId"); const avatarUrl = optionalNonEmpty(input.avatarUrl, "Feishu avatarUrl"); const principalId = scopedFeishuPrincipalId("USER", connectionId, openId); const userId = deterministicFeishuUserId(connectionId, openId); const connection = await requireActiveFeishuApplicationConnectionInTransaction(prisma, connectionId); if (input.expectedOrganizationId !== undefined && input.expectedOrganizationId !== connection.organizationId) { throw new Error("Feishu identity Organization scope mismatch"); } const identity = await prisma.feishuUserIdentity.upsert({ where: { connectionId_openId: { connectionId, openId } }, update: { ...(unionId !== undefined ? { unionId } : {}), user: { update: { displayName, ...(avatarUrl !== undefined ? { avatarUrl } : {}), }, }, }, create: { openId, ...(unionId !== undefined ? { unionId } : {}), connection: { connect: { id: connectionId } }, user: { connectOrCreate: { where: { id: userId }, create: { id: userId, feishuOpenId: principalId, displayName, ...(avatarUrl !== undefined ? { avatarUrl } : {}), }, }, }, }, include: { user: true }, }); return scopedIdentity(connection.organizationId, identity); } export async function resolveScopedFeishuIdentity( prisma: PrismaClient, input: { readonly connectionId: string; readonly openId: string; readonly expectedOrganizationId?: string | undefined; }, ): Promise { const connectionId = nonEmpty(input.connectionId, "Feishu connectionId"); const openId = nonEmpty(input.openId, "Feishu openId"); return prisma.$transaction(async (tx) => { const connection = await requireActiveFeishuApplicationConnectionInTransaction(tx, connectionId); if (input.expectedOrganizationId !== undefined && input.expectedOrganizationId !== connection.organizationId) { throw new Error("Feishu identity Organization scope mismatch"); } const identity = await tx.feishuUserIdentity.findUnique({ where: { connectionId_openId: { connectionId, openId } }, include: { user: true }, }); return identity === null ? null : scopedIdentity(connection.organizationId, identity); }); } /** * Lock and prove that a Feishu application connection is currently usable. * Callers handling provider-originated data use this before trusting a * connection-scoped identifier. */ export async function requireActiveFeishuApplicationConnectionInTransaction( prisma: Prisma.TransactionClient, connectionId: string, ): Promise<{ readonly organizationId: string }> { const scope = await prisma.organizationFeishuApplicationConnection.findUnique({ where: { id: connectionId }, select: { organizationId: true }, }); if (scope === null) throw new Error("active Feishu Application Connection not found"); await lockActiveOrganization(prisma, scope.organizationId); await prisma.$queryRaw` SELECT "id" FROM "OrganizationFeishuApplicationConnection" WHERE "id" = ${connectionId} FOR SHARE `; const connection = await prisma.organizationFeishuApplicationConnection.findUnique({ where: { id: connectionId }, select: { organizationId: true, status: true, activeSecretVersion: { select: { connectionId: true, retiredAt: true } }, }, }); if (connection === null || connection.status !== "ACTIVE" || connection.activeSecretVersion === null || connection.organizationId !== scope.organizationId || connection.activeSecretVersion.connectionId !== connectionId || connection.activeSecretVersion.retiredAt !== null) { throw new Error("active Feishu Application Connection not found"); } return { organizationId: connection.organizationId }; } function scopedIdentity( organizationId: string, identity: { readonly id: string; readonly connectionId: string; readonly userId: string; readonly openId: string; readonly unionId: string | null; readonly user: { readonly displayName: string; readonly avatarUrl: string | null; }; }, ): ScopedFeishuIdentity { return { identityId: identity.id, connectionId: identity.connectionId, organizationId, userId: identity.userId, openId: identity.openId, unionId: identity.unionId, principalId: scopedFeishuPrincipalId("USER", identity.connectionId, identity.openId), displayName: identity.user.displayName, avatarUrl: identity.user.avatarUrl, }; } export function deterministicFeishuUserId(connectionId: string, openId: string): string { const digest = createHash("sha256") .update("cph-feishu-user\0") .update(connectionId, "utf8") .update("\0") .update(openId, "utf8") .digest("base64url"); return `feishu_${digest}`; } function nonEmpty(value: string, label: string): string { const normalized = value.trim(); if (normalized === "") throw new Error(`${label} is required`); return normalized; } function optionalNonEmpty(value: string | undefined, label: string): string | undefined { if (value === undefined) return undefined; return nonEmpty(value, label); }