forked from bai/curriculum-project-hub
feat: secure organization Feishu credentials
This commit is contained in:
@@ -0,0 +1,499 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import type { Prisma, PrismaClient } from "@prisma/client";
|
||||
import { lockActiveOrganization } from "../org/status.js";
|
||||
import { LocalSecretEnvelope, type SecretEnvelopeV1 } from "../security/secretEnvelope.js";
|
||||
import { probeFeishuApplication, type FeishuReadinessProbe } from "./feishuReadiness.js";
|
||||
|
||||
export interface FeishuApplicationCredentialInput {
|
||||
readonly appId: string;
|
||||
readonly appSecret: string;
|
||||
readonly botOpenId: string;
|
||||
readonly verificationToken?: string;
|
||||
readonly encryptKey?: string;
|
||||
}
|
||||
|
||||
export interface RotateFeishuApplicationInput extends FeishuApplicationCredentialInput {
|
||||
readonly organizationId: string;
|
||||
readonly actorUserId: string;
|
||||
}
|
||||
|
||||
export interface FeishuApplicationConnectionMetadata {
|
||||
readonly id: string;
|
||||
readonly appFingerprint: string;
|
||||
readonly status: "DRAFT" | "ACTIVE" | "DISABLED";
|
||||
readonly activeVersion: number | null;
|
||||
readonly keyId: string | null;
|
||||
readonly createdAt: Date;
|
||||
readonly updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface ResolvedFeishuApplication extends Required<FeishuApplicationCredentialInput> {
|
||||
readonly connectionId: string;
|
||||
readonly organizationId: string;
|
||||
}
|
||||
|
||||
interface FeishuSecretPayloadV1 extends Required<FeishuApplicationCredentialInput> {
|
||||
readonly schemaVersion: 1;
|
||||
}
|
||||
|
||||
export class FeishuApplicationConnectionService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly secrets: LocalSecretEnvelope,
|
||||
private readonly readinessProbe: FeishuReadinessProbe = probeFeishuApplication,
|
||||
) {}
|
||||
|
||||
async rotateCustomerApplication(
|
||||
input: RotateFeishuApplicationInput,
|
||||
): Promise<FeishuApplicationConnectionMetadata & { readonly created: boolean }> {
|
||||
const payload = validateCredential(input);
|
||||
const appIdentityFingerprint = fingerprintAppId(payload.appId);
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await requireFeishuAdmin(tx, input);
|
||||
await assertAppIdentityAvailable(tx, input.organizationId, appIdentityFingerprint);
|
||||
});
|
||||
await this.readinessProbe(payload);
|
||||
|
||||
try {
|
||||
return await this.prisma.$transaction(async (tx) => {
|
||||
await requireFeishuAdmin(tx, input);
|
||||
await assertAppIdentityAvailable(tx, input.organizationId, appIdentityFingerprint);
|
||||
const connection = await tx.organizationFeishuApplicationConnection.upsert({
|
||||
where: { organizationId: input.organizationId },
|
||||
update: {},
|
||||
create: {
|
||||
id: randomUUID(),
|
||||
organizationId: input.organizationId,
|
||||
appIdentityFingerprint,
|
||||
status: "DRAFT",
|
||||
},
|
||||
});
|
||||
await tx.$queryRaw`
|
||||
SELECT "id" FROM "OrganizationFeishuApplicationConnection"
|
||||
WHERE "id" = ${connection.id} FOR UPDATE
|
||||
`;
|
||||
const locked = await tx.organizationFeishuApplicationConnection.findUniqueOrThrow({
|
||||
where: { id: connection.id },
|
||||
include: {
|
||||
activeSecretVersion: true,
|
||||
secretVersions: { orderBy: { version: "desc" }, take: 1, select: { version: true } },
|
||||
},
|
||||
});
|
||||
if (locked.organizationId !== input.organizationId) {
|
||||
throw new Error("Feishu Application Connection scope changed during rotation");
|
||||
}
|
||||
if (locked.appIdentityFingerprint !== appIdentityFingerprint) {
|
||||
throw new Error("Feishu Application Connection cannot change app identity");
|
||||
}
|
||||
if (locked.activeSecretVersion !== null) {
|
||||
if (locked.activeSecretVersion.connectionId !== locked.id) {
|
||||
throw new Error("Feishu Application Connection active version scope mismatch");
|
||||
}
|
||||
decryptFeishuCredential(this.secrets, {
|
||||
organizationId: locked.organizationId,
|
||||
connectionId: locked.id,
|
||||
appIdentityFingerprint: locked.appIdentityFingerprint,
|
||||
secretVersionId: locked.activeSecretVersion.id,
|
||||
envelopeVersion: locked.activeSecretVersion.envelopeVersion,
|
||||
keyId: locked.activeSecretVersion.keyId,
|
||||
envelope: locked.activeSecretVersion.envelope,
|
||||
});
|
||||
}
|
||||
const version = (locked.secretVersions[0]?.version ?? 0) + 1;
|
||||
const secretVersionId = randomUUID();
|
||||
const envelope = this.secrets.encryptJson({
|
||||
purpose: "feishu-application",
|
||||
organizationId: input.organizationId,
|
||||
connectionId: locked.id,
|
||||
secretVersionId,
|
||||
}, payload);
|
||||
const now = new Date();
|
||||
const secretVersion = await tx.feishuApplicationCredentialVersion.create({
|
||||
data: {
|
||||
id: secretVersionId,
|
||||
connectionId: locked.id,
|
||||
version,
|
||||
envelopeVersion: envelope.version,
|
||||
keyId: envelope.keyId,
|
||||
envelope: envelope as unknown as Prisma.InputJsonValue,
|
||||
createdByUserId: input.actorUserId,
|
||||
},
|
||||
});
|
||||
if (locked.activeSecretVersion !== null) {
|
||||
await tx.feishuApplicationCredentialVersion.update({
|
||||
where: { id: locked.activeSecretVersion.id },
|
||||
data: { retiredAt: now },
|
||||
});
|
||||
}
|
||||
const activated = await tx.organizationFeishuApplicationConnection.update({
|
||||
where: { id: locked.id },
|
||||
data: {
|
||||
status: "ACTIVE",
|
||||
activeSecretVersionId: secretVersion.id,
|
||||
activatedAt: now,
|
||||
disabledAt: null,
|
||||
},
|
||||
});
|
||||
const action = version === 1
|
||||
? "feishu_application.created"
|
||||
: locked.status === "DISABLED"
|
||||
? "feishu_application.reactivated"
|
||||
: "feishu_application.rotated";
|
||||
await tx.auditEntry.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
actorUserId: input.actorUserId,
|
||||
action,
|
||||
metadata: {
|
||||
connectionId: locked.id,
|
||||
appFingerprint: redactedFingerprint(appIdentityFingerprint),
|
||||
status: "ACTIVE",
|
||||
secretVersion: version,
|
||||
envelopeVersion: envelope.version,
|
||||
keyId: envelope.keyId,
|
||||
},
|
||||
},
|
||||
});
|
||||
return {
|
||||
...metadata(activated, { version, keyId: secretVersion.keyId }),
|
||||
created: version === 1,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
if (isAppFingerprintUniqueViolation(error)) {
|
||||
throw new Error("Feishu Application cannot be shared across Organizations", { cause: error });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async readMetadata(organizationId: string): Promise<FeishuApplicationConnectionMetadata | null> {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await lockActiveOrganization(tx, organizationId);
|
||||
const connection = await tx.organizationFeishuApplicationConnection.findUnique({
|
||||
where: { organizationId },
|
||||
include: { activeSecretVersion: { select: { version: true, keyId: true } } },
|
||||
});
|
||||
return connection === null ? null : metadata(connection, connection.activeSecretVersion);
|
||||
});
|
||||
}
|
||||
|
||||
async disable(input: {
|
||||
readonly organizationId: string;
|
||||
readonly actorUserId: string;
|
||||
}): Promise<FeishuApplicationConnectionMetadata> {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await requireFeishuAdmin(tx, input);
|
||||
const connection = await tx.organizationFeishuApplicationConnection.findUnique({
|
||||
where: { organizationId: input.organizationId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (connection === null) throw new Error("Feishu Application Connection not found");
|
||||
await tx.$queryRaw`
|
||||
SELECT "id" FROM "OrganizationFeishuApplicationConnection"
|
||||
WHERE "id" = ${connection.id} FOR UPDATE
|
||||
`;
|
||||
const locked = await tx.organizationFeishuApplicationConnection.findUniqueOrThrow({
|
||||
where: { id: connection.id },
|
||||
include: { activeSecretVersion: { select: { version: true, keyId: true } } },
|
||||
});
|
||||
if (locked.status === "DISABLED") return metadata(locked, locked.activeSecretVersion);
|
||||
const disabled = await tx.organizationFeishuApplicationConnection.update({
|
||||
where: { id: locked.id },
|
||||
data: { status: "DISABLED", disabledAt: new Date() },
|
||||
});
|
||||
await tx.auditEntry.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
actorUserId: input.actorUserId,
|
||||
action: "feishu_application.disabled",
|
||||
metadata: {
|
||||
connectionId: locked.id,
|
||||
appFingerprint: redactedFingerprint(locked.appIdentityFingerprint),
|
||||
previousStatus: locked.status,
|
||||
status: "DISABLED",
|
||||
},
|
||||
},
|
||||
});
|
||||
return metadata(disabled, locked.activeSecretVersion);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveActiveFeishuApplication(
|
||||
prisma: PrismaClient,
|
||||
secrets: LocalSecretEnvelope,
|
||||
scope: { readonly organizationId: string } | { readonly connectionId: string },
|
||||
): Promise<ResolvedFeishuApplication> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const connection = "organizationId" in scope
|
||||
? await tx.organizationFeishuApplicationConnection.findUnique({
|
||||
where: { organizationId: scope.organizationId },
|
||||
include: { activeSecretVersion: true },
|
||||
})
|
||||
: await tx.organizationFeishuApplicationConnection.findUnique({
|
||||
where: { id: scope.connectionId },
|
||||
include: { activeSecretVersion: true },
|
||||
});
|
||||
if (connection === null) throw new Error("active Feishu Application Connection not found");
|
||||
await lockActiveOrganization(tx, connection.organizationId);
|
||||
const version = connection.activeSecretVersion;
|
||||
if (connection.status !== "ACTIVE" || version === null || version.retiredAt !== null ||
|
||||
version.connectionId !== connection.id) {
|
||||
throw new Error("active Feishu Application Connection not found");
|
||||
}
|
||||
const payload = decryptFeishuCredential(secrets, {
|
||||
organizationId: connection.organizationId,
|
||||
connectionId: connection.id,
|
||||
appIdentityFingerprint: connection.appIdentityFingerprint,
|
||||
secretVersionId: version.id,
|
||||
envelopeVersion: version.envelopeVersion,
|
||||
keyId: version.keyId,
|
||||
envelope: version.envelope,
|
||||
});
|
||||
return {
|
||||
connectionId: connection.id,
|
||||
organizationId: connection.organizationId,
|
||||
appId: payload.appId,
|
||||
appSecret: payload.appSecret,
|
||||
botOpenId: payload.botOpenId,
|
||||
verificationToken: payload.verificationToken,
|
||||
encryptKey: payload.encryptKey,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function decryptFeishuCredential(
|
||||
secrets: LocalSecretEnvelope,
|
||||
input: {
|
||||
readonly organizationId: string;
|
||||
readonly connectionId: string;
|
||||
readonly appIdentityFingerprint: string;
|
||||
readonly secretVersionId: string;
|
||||
readonly envelopeVersion: number;
|
||||
readonly keyId: string;
|
||||
readonly envelope: Prisma.JsonValue;
|
||||
},
|
||||
): FeishuSecretPayloadV1 {
|
||||
if (input.envelopeVersion !== 1) throw new Error("unsupported Feishu secret envelope version");
|
||||
const envelope = input.envelope as unknown as SecretEnvelopeV1;
|
||||
if (envelope.version !== input.envelopeVersion || envelope.keyId !== input.keyId) {
|
||||
throw new Error("Feishu secret envelope metadata mismatch");
|
||||
}
|
||||
const payload = secrets.decryptJson<unknown>({
|
||||
purpose: "feishu-application",
|
||||
organizationId: input.organizationId,
|
||||
connectionId: input.connectionId,
|
||||
secretVersionId: input.secretVersionId,
|
||||
}, envelope);
|
||||
const validated = validateStoredPayload(payload);
|
||||
if (fingerprintAppId(validated.appId) !== input.appIdentityFingerprint) {
|
||||
throw new Error("Feishu Application app identity fingerprint mismatch");
|
||||
}
|
||||
return validated;
|
||||
}
|
||||
|
||||
export async function verifyStoredFeishuApplicationEnvelopes(
|
||||
prisma: PrismaClient | Prisma.TransactionClient,
|
||||
secrets: LocalSecretEnvelope,
|
||||
): Promise<number> {
|
||||
const versions = await prisma.feishuApplicationCredentialVersion.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
envelopeVersion: true,
|
||||
keyId: true,
|
||||
envelope: true,
|
||||
connection: { select: { id: true, organizationId: true, appIdentityFingerprint: true } },
|
||||
},
|
||||
});
|
||||
for (const version of versions) {
|
||||
decryptFeishuCredential(secrets, {
|
||||
organizationId: version.connection.organizationId,
|
||||
connectionId: version.connection.id,
|
||||
appIdentityFingerprint: version.connection.appIdentityFingerprint,
|
||||
secretVersionId: version.id,
|
||||
envelopeVersion: version.envelopeVersion,
|
||||
keyId: version.keyId,
|
||||
envelope: version.envelope,
|
||||
});
|
||||
}
|
||||
return versions.length;
|
||||
}
|
||||
|
||||
export async function rewrapStoredFeishuApplicationEnvelopes(
|
||||
prisma: Prisma.TransactionClient,
|
||||
secrets: LocalSecretEnvelope,
|
||||
): Promise<{ readonly verified: number; readonly rewrapped: number }> {
|
||||
const versions = await prisma.feishuApplicationCredentialVersion.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
envelopeVersion: true,
|
||||
keyId: true,
|
||||
envelope: true,
|
||||
connection: { select: { id: true, organizationId: true, appIdentityFingerprint: true } },
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
let rewrapped = 0;
|
||||
for (const version of versions) {
|
||||
const binding = {
|
||||
purpose: "feishu-application",
|
||||
organizationId: version.connection.organizationId,
|
||||
connectionId: version.connection.id,
|
||||
secretVersionId: version.id,
|
||||
} as const;
|
||||
decryptFeishuCredential(secrets, {
|
||||
organizationId: version.connection.organizationId,
|
||||
connectionId: version.connection.id,
|
||||
appIdentityFingerprint: version.connection.appIdentityFingerprint,
|
||||
secretVersionId: version.id,
|
||||
envelopeVersion: version.envelopeVersion,
|
||||
keyId: version.keyId,
|
||||
envelope: version.envelope,
|
||||
});
|
||||
if (version.keyId === secrets.activeKeyId) continue;
|
||||
const nextEnvelope = secrets.rewrap(binding, version.envelope as unknown as SecretEnvelopeV1);
|
||||
const updated = await prisma.feishuApplicationCredentialVersion.updateMany({
|
||||
where: { id: version.id, keyId: version.keyId },
|
||||
data: {
|
||||
keyId: nextEnvelope.keyId,
|
||||
envelope: nextEnvelope as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
if (updated.count !== 1) {
|
||||
throw new Error(`Feishu secret envelope changed during KEK rotation: ${version.id}`);
|
||||
}
|
||||
await prisma.auditEntry.create({
|
||||
data: {
|
||||
organizationId: version.connection.organizationId,
|
||||
action: "feishu_application_secret.kek_rewrapped",
|
||||
metadata: {
|
||||
connectionId: version.connection.id,
|
||||
secretVersionId: version.id,
|
||||
previousKeyId: version.keyId,
|
||||
keyId: nextEnvelope.keyId,
|
||||
},
|
||||
},
|
||||
});
|
||||
rewrapped += 1;
|
||||
}
|
||||
const verified = await verifyStoredFeishuApplicationEnvelopes(prisma, secrets);
|
||||
const remaining = await prisma.feishuApplicationCredentialVersion.count({
|
||||
where: { keyId: { not: secrets.activeKeyId } },
|
||||
});
|
||||
if (remaining !== 0) {
|
||||
throw new Error(`KEK rotation left ${remaining} Feishu envelope(s) on a non-active key`);
|
||||
}
|
||||
return { verified, rewrapped };
|
||||
}
|
||||
|
||||
function validateCredential(input: FeishuApplicationCredentialInput): FeishuSecretPayloadV1 {
|
||||
const appId = nonEmpty(input.appId, "Feishu appId");
|
||||
const appSecret = nonEmpty(input.appSecret, "Feishu appSecret");
|
||||
const botOpenId = nonEmpty(input.botOpenId, "Feishu botOpenId");
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
appId,
|
||||
appSecret,
|
||||
botOpenId,
|
||||
verificationToken: input.verificationToken?.trim() ?? "",
|
||||
encryptKey: input.encryptKey?.trim() ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function validateStoredPayload(value: unknown): FeishuSecretPayloadV1 {
|
||||
if (!isRecord(value) || value["schemaVersion"] !== 1 ||
|
||||
typeof value["appId"] !== "string" || value["appId"] === "" ||
|
||||
typeof value["appSecret"] !== "string" || value["appSecret"] === "" ||
|
||||
typeof value["botOpenId"] !== "string" || value["botOpenId"] === "" ||
|
||||
typeof value["verificationToken"] !== "string" || typeof value["encryptKey"] !== "string") {
|
||||
throw new Error("invalid encrypted Feishu Application credential payload");
|
||||
}
|
||||
return value as unknown as FeishuSecretPayloadV1;
|
||||
}
|
||||
|
||||
async function requireFeishuAdmin(
|
||||
tx: Prisma.TransactionClient,
|
||||
input: Pick<RotateFeishuApplicationInput, "organizationId" | "actorUserId">,
|
||||
): Promise<void> {
|
||||
await lockActiveOrganization(tx, input.organizationId);
|
||||
const actor = await tx.organizationMembership.findFirst({
|
||||
where: {
|
||||
organizationId: input.organizationId,
|
||||
userId: input.actorUserId,
|
||||
role: { in: ["OWNER", "ADMIN"] },
|
||||
revokedAt: null,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (actor === null) {
|
||||
throw new Error("Feishu Application Connection write requires an active Organization OWNER or ADMIN");
|
||||
}
|
||||
}
|
||||
|
||||
async function assertAppIdentityAvailable(
|
||||
tx: Prisma.TransactionClient,
|
||||
organizationId: string,
|
||||
appIdentityFingerprint: string,
|
||||
): Promise<void> {
|
||||
const organizationConnection = await tx.organizationFeishuApplicationConnection.findUnique({
|
||||
where: { organizationId },
|
||||
select: { appIdentityFingerprint: true },
|
||||
});
|
||||
if (organizationConnection !== null &&
|
||||
organizationConnection.appIdentityFingerprint !== appIdentityFingerprint) {
|
||||
throw new Error("Feishu Application Connection cannot change app identity");
|
||||
}
|
||||
const appConnection = await tx.organizationFeishuApplicationConnection.findUnique({
|
||||
where: { appIdentityFingerprint },
|
||||
select: { organizationId: true },
|
||||
});
|
||||
if (appConnection !== null && appConnection.organizationId !== organizationId) {
|
||||
throw new Error("Feishu Application cannot be shared across Organizations");
|
||||
}
|
||||
}
|
||||
|
||||
function metadata(
|
||||
connection: {
|
||||
readonly id: string;
|
||||
readonly appIdentityFingerprint: string;
|
||||
readonly status: "DRAFT" | "ACTIVE" | "DISABLED";
|
||||
readonly createdAt: Date;
|
||||
readonly updatedAt: Date;
|
||||
},
|
||||
secret: { readonly version: number; readonly keyId: string } | null,
|
||||
): FeishuApplicationConnectionMetadata {
|
||||
return {
|
||||
id: connection.id,
|
||||
appFingerprint: redactedFingerprint(connection.appIdentityFingerprint),
|
||||
status: connection.status,
|
||||
activeVersion: secret?.version ?? null,
|
||||
keyId: secret?.keyId ?? null,
|
||||
createdAt: connection.createdAt,
|
||||
updatedAt: connection.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function fingerprintAppId(appId: string): string {
|
||||
return createHash("sha256").update("cph-feishu-app\0").update(appId, "utf8").digest("hex");
|
||||
}
|
||||
|
||||
function redactedFingerprint(fingerprint: string): string {
|
||||
return `sha256:${fingerprint.slice(0, 16)}`;
|
||||
}
|
||||
|
||||
function isAppFingerprintUniqueViolation(error: unknown): boolean {
|
||||
if (!isRecord(error) || error["code"] !== "P2002" || !isRecord(error["meta"])) return false;
|
||||
const target = error["meta"]["target"];
|
||||
return Array.isArray(target) && target.includes("appIdentityFingerprint");
|
||||
}
|
||||
|
||||
function nonEmpty(value: string, label: string): string {
|
||||
const normalized = value.trim();
|
||||
if (normalized === "") throw new Error(`${label} is required`);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { classifyNetworkFailure, type NetworkFailureCategory } from "./networkFailure.js";
|
||||
|
||||
export interface FeishuReadinessInput {
|
||||
readonly appId: string;
|
||||
readonly appSecret: string;
|
||||
readonly botOpenId: string;
|
||||
}
|
||||
|
||||
export type FeishuReadinessProbe = (input: FeishuReadinessInput) => Promise<void>;
|
||||
|
||||
export class FeishuReadinessError extends Error {
|
||||
constructor(
|
||||
readonly code: "feishu_readiness_unreachable" | "feishu_readiness_rejected",
|
||||
message: string,
|
||||
readonly category: NetworkFailureCategory | "http" | "provider_rejection",
|
||||
readonly upstreamStatus?: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "FeishuReadinessError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Obtain a tenant token, prove the configured bot identity, then discard it. */
|
||||
export async function probeFeishuApplication(input: FeishuReadinessInput): Promise<void> {
|
||||
const response = await feishuFetch(
|
||||
"https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", accept: "application/json" },
|
||||
body: JSON.stringify({ app_id: input.appId, app_secret: input.appSecret }),
|
||||
redirect: "manual",
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel();
|
||||
throw new FeishuReadinessError(
|
||||
"feishu_readiness_rejected",
|
||||
`Feishu application readiness check failed: status ${response.status}`,
|
||||
"http",
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
let payload: unknown;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
throw new FeishuReadinessError(
|
||||
"feishu_readiness_rejected",
|
||||
"Feishu application readiness check returned an invalid response",
|
||||
"provider_rejection",
|
||||
);
|
||||
}
|
||||
if (!isRecord(payload) || payload["code"] !== 0 ||
|
||||
typeof payload["tenant_access_token"] !== "string" || payload["tenant_access_token"] === "") {
|
||||
throw new FeishuReadinessError(
|
||||
"feishu_readiness_rejected",
|
||||
"Feishu application credentials were rejected",
|
||||
"provider_rejection",
|
||||
);
|
||||
}
|
||||
const botResponse = await feishuFetch("https://open.feishu.cn/open-apis/bot/v3/info", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
authorization: `Bearer ${payload["tenant_access_token"]}`,
|
||||
},
|
||||
redirect: "manual",
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!botResponse.ok) {
|
||||
await botResponse.body?.cancel();
|
||||
throw new FeishuReadinessError(
|
||||
"feishu_readiness_rejected",
|
||||
`Feishu bot readiness check failed: status ${botResponse.status}`,
|
||||
"http",
|
||||
botResponse.status,
|
||||
);
|
||||
}
|
||||
let botPayload: unknown;
|
||||
try {
|
||||
botPayload = await botResponse.json();
|
||||
} catch {
|
||||
throw new FeishuReadinessError(
|
||||
"feishu_readiness_rejected",
|
||||
"Feishu bot readiness check returned an invalid response",
|
||||
"provider_rejection",
|
||||
);
|
||||
}
|
||||
if (!isRecord(botPayload) || botPayload["code"] !== 0 ||
|
||||
!isRecord(botPayload["bot"]) || botPayload["bot"]["open_id"] !== input.botOpenId) {
|
||||
throw new FeishuReadinessError(
|
||||
"feishu_readiness_rejected",
|
||||
"Feishu bot identity does not match the configured botOpenId",
|
||||
"provider_rejection",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function feishuFetch(url: string, init: RequestInit): Promise<Response> {
|
||||
try {
|
||||
return await fetch(url, init);
|
||||
} catch (error) {
|
||||
throw new FeishuReadinessError(
|
||||
"feishu_readiness_unreachable",
|
||||
"Feishu application readiness check could not reach provider",
|
||||
classifyNetworkFailure(error),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
Reference in New Issue
Block a user