forked from EduCraft/curriculum-project-hub
feat: add deployable alpha silo
This commit is contained in:
@@ -1,14 +1,16 @@
|
||||
/**
|
||||
* Feishu web OAuth (user login) for org admin panel.
|
||||
*
|
||||
* Pilot uses the process-global FEISHU_APP_ID/SECRET (same app as the bot).
|
||||
* Per-org encrypted Feishu app secrets are deferred (ADR-0021).
|
||||
* Organization login resolves the intended encrypted Feishu Application
|
||||
* Connection before building the authorize URL or exchanging the code.
|
||||
*
|
||||
* Flow:
|
||||
* 1. GET authorize URL → user consents
|
||||
* 2. callback code → user_access_token (authen/v2/oauth/token)
|
||||
* 3. user_access_token → user info (authen/v1/user_info) → open_id
|
||||
*/
|
||||
*/
|
||||
import { classifyNetworkFailure, type NetworkFailureCategory } from "../../connections/networkFailure.js";
|
||||
|
||||
|
||||
export interface FeishuOAuthConfig {
|
||||
readonly appId: string;
|
||||
@@ -24,10 +26,23 @@ export interface FeishuOAuthConfig {
|
||||
|
||||
export interface FeishuOAuthUser {
|
||||
readonly openId: string;
|
||||
readonly unionId?: string | undefined;
|
||||
readonly displayName: string;
|
||||
readonly avatarUrl: string | null;
|
||||
}
|
||||
|
||||
export class FeishuOAuthError extends Error {
|
||||
constructor(
|
||||
readonly code: "feishu_oauth_unreachable" | "feishu_oauth_rejected" | "feishu_oauth_invalid_response",
|
||||
message: string,
|
||||
readonly category: NetworkFailureCategory | "http" | "provider_rejection" | "invalid_response",
|
||||
readonly upstreamStatus?: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "FeishuOAuthError";
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_AUTHORIZE_BASE = "https://accounts.feishu.cn/open-apis/authen/v1/authorize";
|
||||
const DEFAULT_TOKEN_URL = "https://open.feishu.cn/open-apis/authen/v2/oauth/token";
|
||||
const DEFAULT_USER_INFO_URL = "https://open.feishu.cn/open-apis/authen/v1/user_info";
|
||||
@@ -59,66 +74,118 @@ export async function exchangeCodeForUser(
|
||||
async function exchangeCodeForAccessToken(config: FeishuOAuthConfig, code: string): Promise<string> {
|
||||
const fetchImpl = config.fetchImpl ?? fetch;
|
||||
const tokenUrl = config.tokenUrl ?? DEFAULT_TOKEN_URL;
|
||||
const res = await fetchImpl(tokenUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||
body: JSON.stringify({
|
||||
grant_type: "authorization_code",
|
||||
client_id: config.appId,
|
||||
client_secret: config.appSecret,
|
||||
code,
|
||||
redirect_uri: config.redirectUri,
|
||||
}),
|
||||
});
|
||||
const body = (await res.json()) as {
|
||||
code?: number;
|
||||
access_token?: string;
|
||||
error?: string;
|
||||
error_description?: string;
|
||||
msg?: string;
|
||||
};
|
||||
if (!res.ok || body.code !== 0 || typeof body.access_token !== "string" || body.access_token === "") {
|
||||
const detail = body.error_description ?? body.error ?? body.msg ?? `HTTP ${res.status}`;
|
||||
throw new Error(`Feishu OAuth token exchange failed: ${detail}`);
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetchImpl(tokenUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||
body: JSON.stringify({
|
||||
grant_type: "authorization_code",
|
||||
client_id: config.appId,
|
||||
client_secret: config.appSecret,
|
||||
code,
|
||||
redirect_uri: config.redirectUri,
|
||||
}),
|
||||
redirect: "manual",
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new FeishuOAuthError(
|
||||
"feishu_oauth_unreachable",
|
||||
"Feishu OAuth token exchange could not reach provider",
|
||||
classifyNetworkFailure(error),
|
||||
);
|
||||
}
|
||||
return body.access_token;
|
||||
const body = await oauthJson(res, "token exchange");
|
||||
if (!res.ok || body["code"] !== 0 ||
|
||||
typeof body["access_token"] !== "string" || body["access_token"] === "") {
|
||||
throw new FeishuOAuthError(
|
||||
"feishu_oauth_rejected",
|
||||
"Feishu OAuth token exchange was rejected",
|
||||
res.ok ? "provider_rejection" : "http",
|
||||
res.status,
|
||||
);
|
||||
}
|
||||
return body["access_token"];
|
||||
}
|
||||
|
||||
async function fetchUserInfo(config: FeishuOAuthConfig, userAccessToken: string): Promise<FeishuOAuthUser> {
|
||||
const fetchImpl = config.fetchImpl ?? fetch;
|
||||
const userInfoUrl = config.userInfoUrl ?? DEFAULT_USER_INFO_URL;
|
||||
const res = await fetchImpl(userInfoUrl, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${userAccessToken}` },
|
||||
});
|
||||
const body = (await res.json()) as {
|
||||
code?: number;
|
||||
msg?: string;
|
||||
data?: {
|
||||
open_id?: string;
|
||||
name?: string;
|
||||
en_name?: string;
|
||||
avatar_url?: string;
|
||||
avatar_middle?: string;
|
||||
};
|
||||
};
|
||||
if (!res.ok || body.code !== 0 || body.data === undefined) {
|
||||
throw new Error(`Feishu user_info failed: ${body.msg ?? `HTTP ${res.status}`}`);
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetchImpl(userInfoUrl, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${userAccessToken}` },
|
||||
redirect: "manual",
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new FeishuOAuthError(
|
||||
"feishu_oauth_unreachable",
|
||||
"Feishu OAuth user info could not reach provider",
|
||||
classifyNetworkFailure(error),
|
||||
);
|
||||
}
|
||||
const openId = body.data.open_id;
|
||||
const body = await oauthJson(res, "user info");
|
||||
const data = body["data"];
|
||||
if (!res.ok || body["code"] !== 0 || !isRecord(data)) {
|
||||
throw new FeishuOAuthError(
|
||||
"feishu_oauth_rejected",
|
||||
"Feishu OAuth user info was rejected",
|
||||
res.ok ? "provider_rejection" : "http",
|
||||
res.status,
|
||||
);
|
||||
}
|
||||
const openId = data["open_id"];
|
||||
if (typeof openId !== "string" || openId === "") {
|
||||
throw new Error("Feishu user_info response missing open_id");
|
||||
throw new FeishuOAuthError(
|
||||
"feishu_oauth_invalid_response",
|
||||
"Feishu OAuth user info response is missing open_id",
|
||||
"invalid_response",
|
||||
res.status,
|
||||
);
|
||||
}
|
||||
const displayName =
|
||||
(typeof body.data.name === "string" && body.data.name !== "" ? body.data.name : null) ??
|
||||
(typeof body.data.en_name === "string" && body.data.en_name !== "" ? body.data.en_name : null) ??
|
||||
(typeof data["name"] === "string" && data["name"] !== "" ? data["name"] : null) ??
|
||||
(typeof data["en_name"] === "string" && data["en_name"] !== "" ? data["en_name"] : null) ??
|
||||
openId;
|
||||
const avatar =
|
||||
(typeof body.data.avatar_url === "string" && body.data.avatar_url !== ""
|
||||
? body.data.avatar_url
|
||||
(typeof data["avatar_url"] === "string" && data["avatar_url"] !== ""
|
||||
? data["avatar_url"]
|
||||
: null) ??
|
||||
(typeof body.data.avatar_middle === "string" && body.data.avatar_middle !== ""
|
||||
? body.data.avatar_middle
|
||||
(typeof data["avatar_middle"] === "string" && data["avatar_middle"] !== ""
|
||||
? data["avatar_middle"]
|
||||
: null);
|
||||
return { openId, displayName, avatarUrl: avatar };
|
||||
const unionId = typeof data["union_id"] === "string" && data["union_id"] !== ""
|
||||
? data["union_id"]
|
||||
: undefined;
|
||||
return { openId, ...(unionId !== undefined ? { unionId } : {}), displayName, avatarUrl: avatar };
|
||||
}
|
||||
|
||||
async function oauthJson(response: Response, operation: string): Promise<Record<string, unknown>> {
|
||||
let value: unknown;
|
||||
try {
|
||||
value = await response.json();
|
||||
} catch {
|
||||
throw new FeishuOAuthError(
|
||||
"feishu_oauth_invalid_response",
|
||||
`Feishu OAuth ${operation} returned an invalid response`,
|
||||
"invalid_response",
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
if (!isRecord(value)) {
|
||||
throw new FeishuOAuthError(
|
||||
"feishu_oauth_invalid_response",
|
||||
`Feishu OAuth ${operation} returned an invalid response`,
|
||||
"invalid_response",
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ export const ORG_ADMIN_ROLES: readonly OrganizationMemberRole[] = ["OWNER", "ADM
|
||||
export interface AuthContext {
|
||||
readonly session: SessionPayload;
|
||||
readonly user: User;
|
||||
readonly feishuOpenId: string;
|
||||
readonly authenticationOrganizationId?: string | undefined;
|
||||
}
|
||||
|
||||
export interface OrgAuthContext extends AuthContext {
|
||||
@@ -55,12 +57,45 @@ export async function requireSession(
|
||||
await sendError(reply, 401, "unauthenticated", "session invalid or expired");
|
||||
return null;
|
||||
}
|
||||
if ("feishuIdentityId" in session) {
|
||||
const identity = await deps.prisma.feishuUserIdentity.findUnique({
|
||||
where: { id: session.feishuIdentityId },
|
||||
include: {
|
||||
user: true,
|
||||
connection: {
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
activeSecretVersion: { select: { connectionId: true, retiredAt: true } },
|
||||
organization: { select: { id: true, status: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (identity === null || identity.userId !== session.userId ||
|
||||
identity.connectionId !== session.feishuConnectionId ||
|
||||
identity.connection.organization.id !== session.feishuOrganizationId ||
|
||||
identity.connection.status !== "ACTIVE" ||
|
||||
identity.connection.activeSecretVersion === null ||
|
||||
identity.connection.activeSecretVersion.connectionId !== identity.connection.id ||
|
||||
identity.connection.activeSecretVersion.retiredAt !== null ||
|
||||
identity.connection.organization.status !== "ACTIVE") {
|
||||
await sendError(reply, 401, "unauthenticated", "session identity not found or inactive");
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
session,
|
||||
user: identity.user,
|
||||
feishuOpenId: identity.openId,
|
||||
authenticationOrganizationId: identity.connection.organization.id,
|
||||
};
|
||||
}
|
||||
const user = await deps.prisma.user.findUnique({ where: { id: session.userId } });
|
||||
if (user === null || user.feishuOpenId !== session.feishuOpenId) {
|
||||
await sendError(reply, 401, "unauthenticated", "session user not found");
|
||||
return null;
|
||||
}
|
||||
return { session, user };
|
||||
return { session, user, feishuOpenId: user.feishuOpenId };
|
||||
}
|
||||
|
||||
export async function requireOrgRole(
|
||||
@@ -87,6 +122,11 @@ export async function requireOrgRole(
|
||||
await sendError(reply, 403, "org_not_active", `organization is ${organization.status}`);
|
||||
return null;
|
||||
}
|
||||
if (auth.authenticationOrganizationId !== undefined &&
|
||||
auth.authenticationOrganizationId !== organization.id) {
|
||||
await sendError(reply, 403, "forbidden", "session is scoped to a different organization");
|
||||
return null;
|
||||
}
|
||||
|
||||
const membership = await deps.prisma.organizationMembership.findFirst({
|
||||
where: {
|
||||
|
||||
@@ -15,28 +15,41 @@ export const DEFAULT_SESSION_TTL_SECONDS = 7 * 24 * 60 * 60;
|
||||
/** OAuth state cookie TTL: 10 minutes. */
|
||||
export const OAUTH_STATE_TTL_SECONDS = 10 * 60;
|
||||
|
||||
export interface SessionPayload {
|
||||
readonly userId: string;
|
||||
readonly feishuOpenId: string;
|
||||
interface SessionTimes {
|
||||
readonly iat: number;
|
||||
readonly exp: number;
|
||||
}
|
||||
|
||||
export type SessionIdentity =
|
||||
| {
|
||||
readonly userId: string;
|
||||
readonly feishuOpenId: string;
|
||||
}
|
||||
| {
|
||||
readonly userId: string;
|
||||
readonly feishuIdentityId: string;
|
||||
readonly feishuConnectionId: string;
|
||||
readonly feishuOrganizationId: string;
|
||||
};
|
||||
|
||||
export type SessionPayload = SessionIdentity & SessionTimes;
|
||||
|
||||
export interface OAuthStatePayload {
|
||||
readonly nonce: string;
|
||||
readonly returnTo: string;
|
||||
readonly organizationId?: string | undefined;
|
||||
readonly connectionId?: string | undefined;
|
||||
readonly exp: number;
|
||||
}
|
||||
|
||||
export function signSession(
|
||||
payload: Omit<SessionPayload, "iat" | "exp">,
|
||||
payload: SessionIdentity,
|
||||
secret: string,
|
||||
ttlSeconds: number = DEFAULT_SESSION_TTL_SECONDS,
|
||||
nowSeconds: number = Math.floor(Date.now() / 1000),
|
||||
): string {
|
||||
const full: SessionPayload = {
|
||||
userId: payload.userId,
|
||||
feishuOpenId: payload.feishuOpenId,
|
||||
...payload,
|
||||
iat: nowSeconds,
|
||||
exp: nowSeconds + ttlSeconds,
|
||||
};
|
||||
@@ -48,15 +61,38 @@ export function verifySession(
|
||||
secret: string,
|
||||
nowSeconds: number = Math.floor(Date.now() / 1000),
|
||||
): SessionPayload | null {
|
||||
const payload = verifyJson<SessionPayload>(token, secret);
|
||||
const payload = verifyJson<Record<string, unknown>>(token, secret);
|
||||
if (payload === null) return null;
|
||||
if (typeof payload.userId !== "string" || payload.userId === "") return null;
|
||||
if (typeof payload.feishuOpenId !== "string" || payload.feishuOpenId === "") return null;
|
||||
if (typeof payload.iat !== "number" || typeof payload.exp !== "number") return null;
|
||||
if (payload.exp <= nowSeconds) return null;
|
||||
return payload;
|
||||
if (typeof payload["userId"] !== "string" || payload["userId"] === "") return null;
|
||||
if (typeof payload["iat"] !== "number" || typeof payload["exp"] !== "number") return null;
|
||||
if (payload["exp"] <= nowSeconds) return null;
|
||||
const legacy = typeof payload["feishuOpenId"] === "string" && payload["feishuOpenId"] !== "";
|
||||
const scoped = typeof payload["feishuIdentityId"] === "string" && payload["feishuIdentityId"] !== "" &&
|
||||
typeof payload["feishuConnectionId"] === "string" && payload["feishuConnectionId"] !== "" &&
|
||||
typeof payload["feishuOrganizationId"] === "string" && payload["feishuOrganizationId"] !== "";
|
||||
if (legacy === scoped) return null;
|
||||
if (legacy) {
|
||||
return {
|
||||
userId: payload["userId"],
|
||||
feishuOpenId: payload["feishuOpenId"] as string,
|
||||
iat: payload["iat"],
|
||||
exp: payload["exp"],
|
||||
};
|
||||
}
|
||||
return {
|
||||
userId: payload["userId"],
|
||||
feishuIdentityId: payload["feishuIdentityId"] as string,
|
||||
feishuConnectionId: payload["feishuConnectionId"] as string,
|
||||
feishuOrganizationId: payload["feishuOrganizationId"] as string,
|
||||
iat: payload["iat"],
|
||||
exp: payload["exp"],
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
* OAuth state may be legacy/unscoped or bind both an Organization and its
|
||||
* intended Feishu connection. A half-scoped state is always invalid.
|
||||
*/
|
||||
export function signOAuthState(
|
||||
payload: Omit<OAuthStatePayload, "exp">,
|
||||
secret: string,
|
||||
@@ -64,8 +100,7 @@ export function signOAuthState(
|
||||
nowSeconds: number = Math.floor(Date.now() / 1000),
|
||||
): string {
|
||||
const full: OAuthStatePayload = {
|
||||
nonce: payload.nonce,
|
||||
returnTo: payload.returnTo,
|
||||
...payload,
|
||||
exp: nowSeconds + ttlSeconds,
|
||||
};
|
||||
return signJson(full, secret);
|
||||
@@ -81,7 +116,17 @@ export function verifyOAuthState(
|
||||
if (typeof payload.nonce !== "string" || payload.nonce === "") return null;
|
||||
if (typeof payload.returnTo !== "string") return null;
|
||||
if (typeof payload.exp !== "number" || payload.exp <= nowSeconds) return null;
|
||||
return payload;
|
||||
const hasOrganization = typeof payload.organizationId === "string" && payload.organizationId !== "";
|
||||
const hasConnection = typeof payload.connectionId === "string" && payload.connectionId !== "";
|
||||
if (hasOrganization !== hasConnection) return null;
|
||||
return {
|
||||
nonce: payload.nonce,
|
||||
returnTo: payload.returnTo,
|
||||
...(hasOrganization
|
||||
? { organizationId: payload.organizationId!, connectionId: payload.connectionId! }
|
||||
: {}),
|
||||
exp: payload.exp,
|
||||
};
|
||||
}
|
||||
|
||||
function signJson(value: unknown, secret: string): string {
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface AdminPluginConfig {
|
||||
readonly cookieSecure?: boolean;
|
||||
readonly oauthScope?: string;
|
||||
readonly fetchImpl?: typeof fetch;
|
||||
readonly allowLegacyFeishuOAuth?: boolean;
|
||||
}
|
||||
|
||||
export async function registerAdminPlugin(
|
||||
@@ -40,9 +41,13 @@ export async function registerAdminPlugin(
|
||||
publicBaseUrl: config.publicBaseUrl,
|
||||
feishuAppId: config.feishuAppId,
|
||||
feishuAppSecret: config.feishuAppSecret,
|
||||
secretEnvelope: config.secretEnvelope,
|
||||
cookieSecure,
|
||||
...(config.oauthScope !== undefined ? { oauthScope: config.oauthScope } : {}),
|
||||
...(config.fetchImpl !== undefined ? { fetchImpl: config.fetchImpl } : {}),
|
||||
...(config.allowLegacyFeishuOAuth !== undefined
|
||||
? { allowLegacyFeishuOAuth: config.allowLegacyFeishuOAuth }
|
||||
: {}),
|
||||
});
|
||||
|
||||
await registerOrgRoutes(app, {
|
||||
|
||||
@@ -4,10 +4,14 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { resolveActiveFeishuApplication } from "../../connections/feishuApplicationConnections.js";
|
||||
import { upsertScopedFeishuIdentity } from "../../feishu/identityNamespace.js";
|
||||
import type { LocalSecretEnvelope } from "../../security/secretEnvelope.js";
|
||||
import {
|
||||
buildAuthorizeUrl,
|
||||
DEFAULT_OAUTH_SCOPE,
|
||||
exchangeCodeForUser,
|
||||
FeishuOAuthError,
|
||||
type FeishuOAuthConfig,
|
||||
} from "../auth/feishuOAuth.js";
|
||||
import {
|
||||
@@ -21,6 +25,7 @@ import {
|
||||
signOAuthState,
|
||||
signSession,
|
||||
verifyOAuthState,
|
||||
type SessionIdentity,
|
||||
} from "../auth/session.js";
|
||||
import { handleRouteError } from "../errors.js";
|
||||
|
||||
@@ -30,15 +35,17 @@ export interface AuthRouteConfig {
|
||||
readonly publicBaseUrl: string;
|
||||
readonly feishuAppId: string;
|
||||
readonly feishuAppSecret: string;
|
||||
readonly secretEnvelope: LocalSecretEnvelope;
|
||||
readonly oauthScope?: string;
|
||||
readonly cookieSecure: boolean;
|
||||
readonly fetchImpl?: typeof fetch;
|
||||
readonly allowLegacyFeishuOAuth?: boolean;
|
||||
}
|
||||
|
||||
export async function registerAuthRoutes(app: FastifyInstance, config: AuthRouteConfig): Promise<void> {
|
||||
const guardDeps: GuardDeps = { prisma: config.prisma, sessionSecret: config.sessionSecret };
|
||||
const redirectUri = `${trimTrailingSlash(config.publicBaseUrl)}/auth/feishu/callback`;
|
||||
const oauthConfig: FeishuOAuthConfig = {
|
||||
const legacyOauthConfig: FeishuOAuthConfig = {
|
||||
appId: config.feishuAppId,
|
||||
appSecret: config.feishuAppSecret,
|
||||
redirectUri,
|
||||
@@ -46,25 +53,61 @@ export async function registerAuthRoutes(app: FastifyInstance, config: AuthRoute
|
||||
...(config.fetchImpl !== undefined ? { fetchImpl: config.fetchImpl } : {}),
|
||||
};
|
||||
|
||||
app.get("/auth/feishu", async (request, reply) => {
|
||||
if (config.allowLegacyFeishuOAuth === true) {
|
||||
app.get("/auth/feishu", async (request, reply) => {
|
||||
try {
|
||||
const returnTo = sanitizeReturnTo(
|
||||
typeof request.query === "object" && request.query !== null && "returnTo" in request.query
|
||||
? String((request.query as { returnTo?: string }).returnTo ?? "")
|
||||
: "",
|
||||
);
|
||||
const nonce = randomBytes(16).toString("hex");
|
||||
const stateToken = signOAuthState({ nonce, returnTo }, config.sessionSecret);
|
||||
setOAuthNonceCookie(reply, nonce, config.cookieSecure);
|
||||
const url = buildAuthorizeUrl(legacyOauthConfig, stateToken);
|
||||
return reply.redirect(url);
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
app.get("/auth/feishu/:orgSlug", async (request, reply) => {
|
||||
try {
|
||||
const { orgSlug } = request.params as { orgSlug: string };
|
||||
const organization = await config.prisma.organization.findUnique({
|
||||
where: { slug: orgSlug },
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
if (organization === null) throw new HttpError(404, "org_not_found", `organization not found: ${orgSlug}`);
|
||||
if (organization.status !== "ACTIVE") {
|
||||
throw new HttpError(403, "org_not_active", `organization is ${organization.status}`);
|
||||
}
|
||||
const credential = await resolveActiveFeishuApplication(
|
||||
config.prisma,
|
||||
config.secretEnvelope,
|
||||
{ organizationId: organization.id },
|
||||
);
|
||||
const returnTo = sanitizeReturnTo(
|
||||
typeof request.query === "object" && request.query !== null && "returnTo" in request.query
|
||||
? String((request.query as { returnTo?: string }).returnTo ?? "")
|
||||
: "",
|
||||
);
|
||||
const nonce = randomBytes(16).toString("hex");
|
||||
const stateToken = signOAuthState({ nonce, returnTo }, config.sessionSecret);
|
||||
// state query param is the signed blob (CSRF + returnTo). Cookie mirrors nonce for double-submit.
|
||||
reply.setCookie(OAUTH_STATE_COOKIE_NAME, nonce, {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: config.cookieSecure,
|
||||
maxAge: 600,
|
||||
});
|
||||
const url = buildAuthorizeUrl(oauthConfig, stateToken);
|
||||
return reply.redirect(url);
|
||||
const stateToken = signOAuthState({
|
||||
nonce,
|
||||
returnTo,
|
||||
organizationId: organization.id,
|
||||
connectionId: credential.connectionId,
|
||||
}, config.sessionSecret);
|
||||
setOAuthNonceCookie(reply, nonce, config.cookieSecure);
|
||||
return reply.redirect(buildAuthorizeUrl({
|
||||
appId: credential.appId,
|
||||
appSecret: credential.appSecret,
|
||||
redirectUri,
|
||||
scope: config.oauthScope ?? DEFAULT_OAUTH_SCOPE,
|
||||
...(config.fetchImpl !== undefined ? { fetchImpl: config.fetchImpl } : {}),
|
||||
}, stateToken));
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
}
|
||||
@@ -96,33 +139,93 @@ export async function registerAuthRoutes(app: FastifyInstance, config: AuthRoute
|
||||
}
|
||||
reply.clearCookie(OAUTH_STATE_COOKIE_NAME, { path: "/" });
|
||||
|
||||
const feishuUser = await exchangeCodeForUser(oauthConfig, code);
|
||||
const user = await config.prisma.user.upsert({
|
||||
where: { feishuOpenId: feishuUser.openId },
|
||||
create: {
|
||||
feishuOpenId: feishuUser.openId,
|
||||
displayName: feishuUser.displayName,
|
||||
avatarUrl: feishuUser.avatarUrl,
|
||||
},
|
||||
update: {
|
||||
displayName: feishuUser.displayName,
|
||||
avatarUrl: feishuUser.avatarUrl,
|
||||
},
|
||||
});
|
||||
let oauthConfig = legacyOauthConfig;
|
||||
if (statePayload.connectionId === undefined && config.allowLegacyFeishuOAuth !== true) {
|
||||
throw new HttpError(400, "bad_request", "unscoped Feishu OAuth is disabled");
|
||||
}
|
||||
if (statePayload.connectionId !== undefined && statePayload.organizationId !== undefined) {
|
||||
const credential = await resolveActiveFeishuApplication(
|
||||
config.prisma,
|
||||
config.secretEnvelope,
|
||||
{ connectionId: statePayload.connectionId },
|
||||
);
|
||||
if (credential.organizationId !== statePayload.organizationId) {
|
||||
throw new HttpError(400, "bad_request", "OAuth state Organization scope mismatch");
|
||||
}
|
||||
oauthConfig = {
|
||||
appId: credential.appId,
|
||||
appSecret: credential.appSecret,
|
||||
redirectUri,
|
||||
scope: config.oauthScope ?? DEFAULT_OAUTH_SCOPE,
|
||||
...(config.fetchImpl !== undefined ? { fetchImpl: config.fetchImpl } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
setSessionCookie(reply, config, {
|
||||
userId: user.id,
|
||||
feishuOpenId: user.feishuOpenId,
|
||||
});
|
||||
const feishuUser = await exchangeCodeForUser(oauthConfig, code);
|
||||
let userId: string;
|
||||
if (statePayload.connectionId !== undefined && statePayload.organizationId !== undefined) {
|
||||
const identity = await upsertScopedFeishuIdentity(config.prisma, {
|
||||
connectionId: statePayload.connectionId,
|
||||
openId: feishuUser.openId,
|
||||
...(feishuUser.unionId !== undefined ? { unionId: feishuUser.unionId } : {}),
|
||||
displayName: feishuUser.displayName,
|
||||
...(feishuUser.avatarUrl !== null ? { avatarUrl: feishuUser.avatarUrl } : {}),
|
||||
});
|
||||
if (identity.organizationId !== statePayload.organizationId) {
|
||||
throw new HttpError(400, "bad_request", "OAuth identity Organization scope mismatch");
|
||||
}
|
||||
userId = identity.userId;
|
||||
setSessionCookie(reply, config, {
|
||||
userId,
|
||||
feishuIdentityId: identity.identityId,
|
||||
feishuConnectionId: identity.connectionId,
|
||||
feishuOrganizationId: identity.organizationId,
|
||||
});
|
||||
} else {
|
||||
const user = await config.prisma.user.upsert({
|
||||
where: { feishuOpenId: feishuUser.openId },
|
||||
create: {
|
||||
feishuOpenId: feishuUser.openId,
|
||||
displayName: feishuUser.displayName,
|
||||
avatarUrl: feishuUser.avatarUrl,
|
||||
},
|
||||
update: {
|
||||
displayName: feishuUser.displayName,
|
||||
avatarUrl: feishuUser.avatarUrl,
|
||||
},
|
||||
});
|
||||
userId = user.id;
|
||||
setSessionCookie(reply, config, {
|
||||
userId,
|
||||
feishuOpenId: user.feishuOpenId,
|
||||
});
|
||||
}
|
||||
|
||||
const destination = await resolvePostLoginRedirect(
|
||||
config.prisma,
|
||||
user.id,
|
||||
userId,
|
||||
statePayload.returnTo,
|
||||
statePayload.organizationId !== undefined
|
||||
? { intendedOrganizationId: statePayload.organizationId }
|
||||
: {},
|
||||
);
|
||||
return reply.redirect(destination);
|
||||
} catch (err) {
|
||||
request.log.error({ err }, "feishu oauth callback failed");
|
||||
request.log.error({
|
||||
requestId: request.id,
|
||||
operation: "feishu_oauth.callback",
|
||||
...(err instanceof FeishuOAuthError
|
||||
? {
|
||||
errorCode: err.code,
|
||||
failureCategory: err.category,
|
||||
...(err.upstreamStatus !== undefined ? { upstreamStatus: err.upstreamStatus } : {}),
|
||||
}
|
||||
: {
|
||||
errorCode: "feishu_oauth_callback_failed",
|
||||
errorType: err instanceof Error ? err.name : typeof err,
|
||||
err,
|
||||
}),
|
||||
}, "feishu oauth callback failed");
|
||||
return reply.redirect(`/admin/login?error=${encodeURIComponent("oauth_failed")}`);
|
||||
}
|
||||
});
|
||||
@@ -138,7 +241,13 @@ export async function registerAuthRoutes(app: FastifyInstance, config: AuthRoute
|
||||
if (auth === null) return;
|
||||
|
||||
const memberships = await config.prisma.organizationMembership.findMany({
|
||||
where: { userId: auth.user.id, revokedAt: null },
|
||||
where: {
|
||||
userId: auth.user.id,
|
||||
revokedAt: null,
|
||||
...(auth.authenticationOrganizationId !== undefined
|
||||
? { organizationId: auth.authenticationOrganizationId }
|
||||
: {}),
|
||||
},
|
||||
select: {
|
||||
role: true,
|
||||
organization: {
|
||||
@@ -151,7 +260,7 @@ export async function registerAuthRoutes(app: FastifyInstance, config: AuthRoute
|
||||
return {
|
||||
user: {
|
||||
id: auth.user.id,
|
||||
feishuOpenId: auth.user.feishuOpenId,
|
||||
feishuOpenId: auth.feishuOpenId,
|
||||
displayName: auth.user.displayName,
|
||||
avatarUrl: auth.user.avatarUrl,
|
||||
},
|
||||
@@ -170,12 +279,16 @@ export async function registerAuthRoutes(app: FastifyInstance, config: AuthRoute
|
||||
|
||||
// Minimal login page until admin SPA lands (PR7).
|
||||
app.get("/admin/login", async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const q = request.query as { error?: string; returnTo?: string };
|
||||
const q = request.query as { error?: string; returnTo?: string; orgSlug?: string };
|
||||
const returnTo = sanitizeReturnTo(q.returnTo ?? "");
|
||||
const loginHref =
|
||||
returnTo === "/admin"
|
||||
? "/auth/feishu"
|
||||
: `/auth/feishu?returnTo=${encodeURIComponent(returnTo)}`;
|
||||
const orgSlug = typeof q.orgSlug === "string" && /^[a-z0-9][a-z0-9-]*$/.test(q.orgSlug)
|
||||
? q.orgSlug
|
||||
: null;
|
||||
const loginHref = orgSlug === null
|
||||
? null
|
||||
: returnTo === "/admin"
|
||||
? `/auth/feishu/${encodeURIComponent(orgSlug)}`
|
||||
: `/auth/feishu/${encodeURIComponent(orgSlug)}?returnTo=${encodeURIComponent(returnTo)}`;
|
||||
const errorHtml =
|
||||
typeof q.error === "string" && q.error !== ""
|
||||
? `<p class="err">Login failed: ${escapeHtml(q.error)}</p>`
|
||||
@@ -199,7 +312,9 @@ export async function registerAuthRoutes(app: FastifyInstance, config: AuthRoute
|
||||
<h1>Org Admin</h1>
|
||||
<p>Sign in with Feishu to manage your organization.</p>
|
||||
${errorHtml}
|
||||
<a class="btn" href="${escapeHtml(loginHref)}">Login with Feishu</a>
|
||||
${loginHref === null
|
||||
? "<p>请从组织专属登录链接进入。</p>"
|
||||
: `<a class="btn" href="${escapeHtml(loginHref)}">Login with Feishu</a>`}
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
@@ -210,7 +325,7 @@ export async function registerAuthRoutes(app: FastifyInstance, config: AuthRoute
|
||||
export function setSessionCookie(
|
||||
reply: FastifyReply,
|
||||
config: Pick<AuthRouteConfig, "sessionSecret" | "cookieSecure">,
|
||||
identity: { readonly userId: string; readonly feishuOpenId: string },
|
||||
identity: SessionIdentity,
|
||||
): void {
|
||||
const token = signSession(identity, config.sessionSecret);
|
||||
reply.setCookie(SESSION_COOKIE_NAME, token, {
|
||||
@@ -224,7 +339,7 @@ export function setSessionCookie(
|
||||
|
||||
/** Exported for tests that need a pre-authenticated cookie value. */
|
||||
export function mintSessionToken(
|
||||
identity: { readonly userId: string; readonly feishuOpenId: string },
|
||||
identity: SessionIdentity,
|
||||
sessionSecret: string,
|
||||
): string {
|
||||
return signSession(identity, sessionSecret);
|
||||
@@ -238,7 +353,22 @@ async function resolvePostLoginRedirect(
|
||||
prisma: PrismaClient,
|
||||
userId: string,
|
||||
returnTo: string,
|
||||
options: { readonly intendedOrganizationId?: string | undefined } = {},
|
||||
): Promise<string> {
|
||||
if (options.intendedOrganizationId !== undefined) {
|
||||
const intended = await prisma.organizationMembership.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
organizationId: options.intendedOrganizationId,
|
||||
revokedAt: null,
|
||||
organization: { status: "ACTIVE" },
|
||||
},
|
||||
select: { organization: { select: { slug: true } } },
|
||||
});
|
||||
if (intended === null) return "/admin?error=not_an_active_org_member";
|
||||
const orgRoot = `/admin/org/${intended.organization.slug}`;
|
||||
return returnTo === orgRoot || returnTo.startsWith(`${orgRoot}/`) ? returnTo : orgRoot;
|
||||
}
|
||||
if (returnTo !== "/admin" && returnTo.startsWith("/admin")) {
|
||||
return returnTo;
|
||||
}
|
||||
@@ -267,6 +397,17 @@ async function resolvePostLoginRedirect(
|
||||
return "/admin/login?error=no_organization";
|
||||
}
|
||||
|
||||
function setOAuthNonceCookie(reply: FastifyReply, nonce: string, secure: boolean): void {
|
||||
// Signed state carries scope/returnTo; the cookie mirrors nonce for double-submit CSRF.
|
||||
reply.setCookie(OAUTH_STATE_COOKIE_NAME, nonce, {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure,
|
||||
maxAge: 600,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Only allow relative same-origin paths under /admin to avoid open redirects.
|
||||
*/
|
||||
|
||||
@@ -130,7 +130,7 @@ export async function registerExplorerRoutes(
|
||||
}
|
||||
const result = await createOrgProject(config.prisma, {
|
||||
organizationId: auth.organization.id,
|
||||
actorFeishuOpenId: auth.user.feishuOpenId,
|
||||
actorFeishuOpenId: auth.feishuOpenId,
|
||||
name: body.name,
|
||||
workspaceRoot: config.projectWorkspaceRoot,
|
||||
...(typeof body.folderId === "string" ? { folderId: body.folderId } : {}),
|
||||
@@ -219,7 +219,7 @@ export async function registerExplorerRoutes(
|
||||
return await archiveOrgProjectChatBinding(config.prisma, {
|
||||
organizationId: auth.organization.id,
|
||||
projectId,
|
||||
actorFeishuOpenId: auth.user.feishuOpenId,
|
||||
actorFeishuOpenId: auth.feishuOpenId,
|
||||
});
|
||||
} catch (err) {
|
||||
return handleRouteError(reply, err);
|
||||
|
||||
@@ -32,7 +32,7 @@ export interface ResolvedFeishuApplication extends Required<FeishuApplicationCre
|
||||
readonly organizationId: string;
|
||||
}
|
||||
|
||||
interface FeishuSecretPayloadV1 extends Required<FeishuApplicationCredentialInput> {
|
||||
export interface FeishuSecretPayloadV1 extends Required<FeishuApplicationCredentialInput> {
|
||||
readonly schemaVersion: 1;
|
||||
}
|
||||
|
||||
@@ -46,8 +46,8 @@ export class FeishuApplicationConnectionService {
|
||||
async rotateCustomerApplication(
|
||||
input: RotateFeishuApplicationInput,
|
||||
): Promise<FeishuApplicationConnectionMetadata & { readonly created: boolean }> {
|
||||
const payload = validateCredential(input);
|
||||
const appIdentityFingerprint = fingerprintAppId(payload.appId);
|
||||
const payload = validateFeishuApplicationCredential(input);
|
||||
const appIdentityFingerprint = fingerprintFeishuAppId(payload.appId);
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await requireFeishuAdmin(tx, input);
|
||||
await assertAppIdentityAvailable(tx, input.organizationId, appIdentityFingerprint);
|
||||
@@ -287,7 +287,7 @@ export function decryptFeishuCredential(
|
||||
secretVersionId: input.secretVersionId,
|
||||
}, envelope);
|
||||
const validated = validateStoredPayload(payload);
|
||||
if (fingerprintAppId(validated.appId) !== input.appIdentityFingerprint) {
|
||||
if (fingerprintFeishuAppId(validated.appId) !== input.appIdentityFingerprint) {
|
||||
throw new Error("Feishu Application app identity fingerprint mismatch");
|
||||
}
|
||||
return validated;
|
||||
@@ -387,7 +387,7 @@ export async function rewrapStoredFeishuApplicationEnvelopes(
|
||||
return { verified, rewrapped };
|
||||
}
|
||||
|
||||
function validateCredential(input: FeishuApplicationCredentialInput): FeishuSecretPayloadV1 {
|
||||
export function validateFeishuApplicationCredential(input: FeishuApplicationCredentialInput): FeishuSecretPayloadV1 {
|
||||
const appId = nonEmpty(input.appId, "Feishu appId");
|
||||
const appSecret = nonEmpty(input.appSecret, "Feishu appSecret");
|
||||
const botOpenId = nonEmpty(input.botOpenId, "Feishu botOpenId");
|
||||
@@ -474,7 +474,7 @@ function metadata(
|
||||
};
|
||||
}
|
||||
|
||||
function fingerprintAppId(appId: string): string {
|
||||
export function fingerprintFeishuAppId(appId: string): string {
|
||||
return createHash("sha256").update("cph-feishu-app\0").update(appId, "utf8").digest("hex");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
#!/usr/bin/env node
|
||||
import { lstat, readFile } from "node:fs/promises";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { bootstrapAlphaSilo, type AlphaSiloBootstrapInput } from "./bootstrap-silo.js";
|
||||
import { loadLocalSecretKeyringFile, LocalSecretEnvelope } from "../security/secretEnvelope.js";
|
||||
import { readSiloOrganizationId } from "./silo.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
async function main(): Promise<void> {
|
||||
if (process.getuid?.() !== 0) throw new Error("Alpha Silo bootstrap must run as root");
|
||||
await requireStoppedSilo();
|
||||
const { configFile, keyringFile } = parseArgs(process.argv.slice(2));
|
||||
await requireRootPrivateFile(configFile, "bootstrap config");
|
||||
await requireRootPrivateFile(keyringFile, "secret keyring");
|
||||
const databaseUrl = process.env["DATABASE_URL"]?.trim();
|
||||
if (databaseUrl === undefined || databaseUrl === "") throw new Error("DATABASE_URL is required");
|
||||
const input = parseConfig(await readFile(configFile, "utf8"));
|
||||
const configuredOrganizationId = readSiloOrganizationId();
|
||||
if (input.organization.id !== configuredOrganizationId) {
|
||||
throw new Error(
|
||||
`bootstrap Organization ${input.organization.id} does not match HUB_SILO_ORGANIZATION_ID ${configuredOrganizationId}`,
|
||||
);
|
||||
}
|
||||
const secrets = new LocalSecretEnvelope(await loadLocalSecretKeyringFile(keyringFile));
|
||||
const prisma = new PrismaClient({ datasources: { db: { url: databaseUrl } }, log: [] });
|
||||
try {
|
||||
const result = await bootstrapAlphaSilo(prisma, secrets, input);
|
||||
console.log(JSON.stringify(result));
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
async function requireStoppedSilo(): Promise<void> {
|
||||
const unit = process.env["HUB_SYSTEMD_UNIT"]?.trim();
|
||||
if (unit === undefined || !/^cph-hub-[a-z0-9][a-z0-9-]*\.service$/.test(unit)) {
|
||||
throw new Error("HUB_SYSTEMD_UNIT must name the Silo being bootstrapped");
|
||||
}
|
||||
let state: string;
|
||||
try {
|
||||
const result = await execFileAsync("systemctl", ["show", "--property=ActiveState", "--value", unit], {
|
||||
timeout: 10_000,
|
||||
});
|
||||
state = result.stdout.trim();
|
||||
} catch (error) {
|
||||
throw new Error(`cannot verify that ${unit} is stopped`, { cause: error });
|
||||
}
|
||||
if (state !== "inactive" && state !== "failed") {
|
||||
throw new Error(`${unit} must be stopped before bootstrap; state=${state || "unknown"}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(argv: readonly string[]): { readonly configFile: string; readonly keyringFile: string } {
|
||||
if (argv.length !== 4 || argv[0] !== "--config-file" || argv[2] !== "--keyring-file" ||
|
||||
argv[1] === undefined || argv[1] === "" || argv[3] === undefined || argv[3] === "") {
|
||||
throw new Error("usage: bootstrap-silo --config-file /root/bootstrap.json --keyring-file /root/keyring.json");
|
||||
}
|
||||
return { configFile: argv[1], keyringFile: argv[3] };
|
||||
}
|
||||
|
||||
async function requireRootPrivateFile(path: string, label: string): Promise<void> {
|
||||
const metadata = await lstat(path);
|
||||
if (metadata.isSymbolicLink() || !metadata.isFile()) throw new Error(`${label} is not a regular file: ${path}`);
|
||||
if (metadata.uid !== 0 || (metadata.mode & 0o077) !== 0) {
|
||||
throw new Error(`${label} must be root-owned and inaccessible by group/others: ${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseConfig(source: string): AlphaSiloBootstrapInput {
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(source);
|
||||
} catch (error) {
|
||||
throw new Error("bootstrap config is not valid JSON", { cause: error });
|
||||
}
|
||||
if (!isRecord(value) || !isRecord(value["organization"]) || !isRecord(value["owner"]) ||
|
||||
!isRecord(value["feishu"]) || !isRecord(value["provider"])) {
|
||||
throw new Error("bootstrap config must contain organization, owner, feishu, and provider objects");
|
||||
}
|
||||
const organization = value["organization"];
|
||||
const owner = value["owner"];
|
||||
const feishu = value["feishu"];
|
||||
const provider = value["provider"];
|
||||
const teams = value["teams"];
|
||||
if (teams !== undefined && !Array.isArray(teams)) throw new Error("bootstrap teams must be an array");
|
||||
return {
|
||||
organization: {
|
||||
id: stringField(organization, "id"),
|
||||
slug: stringField(organization, "slug"),
|
||||
name: stringField(organization, "name"),
|
||||
},
|
||||
owner: {
|
||||
openId: stringField(owner, "openId"),
|
||||
displayName: stringField(owner, "displayName"),
|
||||
...(optionalStringField(owner, "unionId") !== undefined
|
||||
? { unionId: optionalStringField(owner, "unionId")! }
|
||||
: {}),
|
||||
},
|
||||
feishu: {
|
||||
appId: stringField(feishu, "appId"),
|
||||
appSecret: stringField(feishu, "appSecret"),
|
||||
botOpenId: stringField(feishu, "botOpenId"),
|
||||
...(optionalStringField(feishu, "verificationToken") !== undefined
|
||||
? { verificationToken: optionalStringField(feishu, "verificationToken")! }
|
||||
: {}),
|
||||
...(optionalStringField(feishu, "encryptKey") !== undefined
|
||||
? { encryptKey: optionalStringField(feishu, "encryptKey")! }
|
||||
: {}),
|
||||
},
|
||||
provider: {
|
||||
providerId: stringField(provider, "providerId"),
|
||||
baseUrl: stringField(provider, "baseUrl"),
|
||||
authToken: stringField(provider, "authToken"),
|
||||
...(optionalStringField(provider, "anthropicApiKey") !== undefined
|
||||
? { anthropicApiKey: optionalStringField(provider, "anthropicApiKey")! }
|
||||
: {}),
|
||||
},
|
||||
...(Array.isArray(teams)
|
||||
? {
|
||||
teams: teams.map((team, index) => {
|
||||
if (!isRecord(team)) throw new Error(`bootstrap teams[${index}] must be an object`);
|
||||
return {
|
||||
slug: stringField(team, "slug"),
|
||||
name: stringField(team, "name"),
|
||||
...(optionalStringField(team, "description") !== undefined
|
||||
? { description: optionalStringField(team, "description")! }
|
||||
: {}),
|
||||
};
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function stringField(value: Record<string, unknown>, name: string): string {
|
||||
const field = value[name];
|
||||
if (typeof field !== "string" || field.trim() === "") throw new Error(`bootstrap ${name} is required`);
|
||||
return field;
|
||||
}
|
||||
|
||||
function optionalStringField(value: Record<string, unknown>, name: string): string | undefined {
|
||||
const field = value[name];
|
||||
if (field === undefined) return undefined;
|
||||
if (typeof field !== "string") throw new Error(`bootstrap ${name} must be a string`);
|
||||
return field;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(`[bootstrap-silo] failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,365 @@
|
||||
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);
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
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.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() } : {}),
|
||||
};
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
@@ -32,6 +32,7 @@ interface Options {
|
||||
readonly stateDir: string;
|
||||
readonly cacheDir: string;
|
||||
readonly workspaceRoot: string;
|
||||
readonly serviceUnit: string;
|
||||
readonly bwrapBin: string;
|
||||
readonly socatBin: string;
|
||||
readonly pgIsReadyBin: string;
|
||||
@@ -79,6 +80,7 @@ function deploymentInput(options: Options, env: DeploymentEnv): DeploymentPrefli
|
||||
stateDir: options.stateDir,
|
||||
cacheDir: options.cacheDir,
|
||||
workspaceRoot: options.workspaceRoot,
|
||||
expectedServiceUnit: options.serviceUnit,
|
||||
env,
|
||||
};
|
||||
}
|
||||
@@ -97,6 +99,7 @@ async function canonicalizeInput(input: DeploymentPreflightInput): Promise<Deplo
|
||||
stateDir,
|
||||
cacheDir,
|
||||
workspaceRoot,
|
||||
expectedServiceUnit: input.expectedServiceUnit,
|
||||
env: { ...input.env, HUB_PROJECT_WORKSPACE_ROOT: workspaceRoot },
|
||||
};
|
||||
}
|
||||
@@ -155,8 +158,8 @@ async function validateNodeVersion(nodeBin: string): Promise<void> {
|
||||
}
|
||||
const version = stdout.trim().replace(/^v/, "");
|
||||
const major = Number(version.split(".")[0]);
|
||||
if (!Number.isInteger(major) || major < 20) {
|
||||
throw new Error(`Node.js 20 or newer is required; found ${version || "unknown"}`);
|
||||
if (!Number.isInteger(major) || major < 24) {
|
||||
throw new Error(`Node.js 24 or newer is required; found ${version || "unknown"}`);
|
||||
}
|
||||
console.log(`[preflight] Node.js: ${version}`);
|
||||
}
|
||||
@@ -479,6 +482,7 @@ function parseArgs(argv: readonly string[]): Options {
|
||||
"--state-dir",
|
||||
"--cache-dir",
|
||||
"--workspace-root",
|
||||
"--service-unit",
|
||||
"--bwrap-bin",
|
||||
"--socat-bin",
|
||||
"--pg-isready-bin",
|
||||
@@ -508,6 +512,7 @@ function parseArgs(argv: readonly string[]): Options {
|
||||
stateDir: required(values, "--state-dir"),
|
||||
cacheDir: required(values, "--cache-dir"),
|
||||
workspaceRoot: required(values, "--workspace-root"),
|
||||
serviceUnit: required(values, "--service-unit"),
|
||||
bwrapBin: required(values, "--bwrap-bin"),
|
||||
socatBin: required(values, "--socat-bin"),
|
||||
pgIsReadyBin: required(values, "--pg-isready-bin"),
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface DeploymentPreflightInput {
|
||||
readonly stateDir: string;
|
||||
readonly cacheDir: string;
|
||||
readonly workspaceRoot: string;
|
||||
readonly expectedServiceUnit: string;
|
||||
readonly env: DeploymentEnv;
|
||||
}
|
||||
|
||||
@@ -89,9 +90,13 @@ export function validateDeploymentPreflight(input: DeploymentPreflightInput): De
|
||||
}
|
||||
}
|
||||
|
||||
readRequired(input.env, "FEISHU_APP_ID", problems);
|
||||
readRequired(input.env, "FEISHU_APP_SECRET", problems);
|
||||
readRequired(input.env, "FEISHU_BOT_OPEN_ID", problems);
|
||||
readRequired(input.env, "HUB_SILO_ORGANIZATION_ID", problems);
|
||||
const serviceUnit = readRequired(input.env, "HUB_SYSTEMD_UNIT", problems);
|
||||
if (serviceUnit !== undefined && !/^cph-hub-[a-z0-9][a-z0-9-]*\.service$/.test(serviceUnit)) {
|
||||
problems.push("HUB_SYSTEMD_UNIT must match cph-hub-<instance>.service");
|
||||
} else if (serviceUnit !== undefined && serviceUnit !== input.expectedServiceUnit) {
|
||||
problems.push(`HUB_SYSTEMD_UNIT must equal installed unit ${input.expectedServiceUnit}`);
|
||||
}
|
||||
|
||||
const cphBin = readRequired(input.env, "CPH_BIN", problems);
|
||||
if (cphBin !== undefined && !isAbsoluteNormalized(cphBin)) {
|
||||
@@ -99,6 +104,7 @@ export function validateDeploymentPreflight(input: DeploymentPreflightInput): De
|
||||
}
|
||||
|
||||
let bind: ServerBinding | undefined;
|
||||
readRequired(input.env, "PORT", problems);
|
||||
try {
|
||||
bind = readServerBinding(input.env);
|
||||
} catch (error) {
|
||||
@@ -116,6 +122,13 @@ export function validateDeploymentPreflight(input: DeploymentPreflightInput): De
|
||||
}
|
||||
|
||||
validateOptionalPositiveInteger(input.env, "HUB_AGENT_MAX_TURNS", problems);
|
||||
readRequiredPositiveInteger(input.env, "HUB_AGENT_MAX_CONCURRENT_RUNS", problems);
|
||||
readRequiredPositiveInteger(input.env, "HUB_AGENT_MAX_RUN_SECONDS", problems);
|
||||
readRequiredPositiveInteger(input.env, "HUB_HTTP_BODY_LIMIT_BYTES", problems);
|
||||
readRequiredPositiveInteger(input.env, "HUB_MAX_FILES_PER_MESSAGE", problems);
|
||||
readRequiredPositiveInteger(input.env, "HUB_MAX_FILE_BYTES", problems);
|
||||
readRequiredPositiveInteger(input.env, "HUB_HTTP_REQUESTS_PER_MINUTE", problems);
|
||||
readRequiredPositiveInteger(input.env, "HUB_FEISHU_EVENTS_PER_MINUTE", problems);
|
||||
validateOptionalBoolean(input.env, "HUB_FEISHU_LISTENER_ENABLED", problems);
|
||||
|
||||
if (problems.length > 0) {
|
||||
@@ -147,6 +160,14 @@ function validateOptionalPositiveInteger(env: DeploymentEnv, name: string, probl
|
||||
}
|
||||
}
|
||||
|
||||
function readRequiredPositiveInteger(env: DeploymentEnv, name: string, problems: string[]): void {
|
||||
const value = readRequired(env, name, problems);
|
||||
if (value === undefined) return;
|
||||
if (!Number.isInteger(Number(value)) || Number(value) <= 0) {
|
||||
problems.push(`${name} must be a positive integer`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateOptionalBoolean(env: DeploymentEnv, name: string, problems: string[]): void {
|
||||
const raw = env[name]?.trim().toLowerCase();
|
||||
if (raw === undefined || raw === "") return;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env node
|
||||
import { realpath, stat } from "node:fs/promises";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { verifyStoredFeishuApplicationEnvelopes } from "../connections/feishuApplicationConnections.js";
|
||||
import { verifyStoredProviderEnvelopes } from "../connections/providerConnections.js";
|
||||
import { loadLocalSecretKeyringFile, LocalSecretEnvelope } from "../security/secretEnvelope.js";
|
||||
import { readSiloOrganizationId, requireSiloOrganization } from "./silo.js";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const keyringFile = parseArgs(process.argv.slice(2));
|
||||
const databaseUrl = process.env["DATABASE_URL"]?.trim();
|
||||
const workspaceRoot = process.env["HUB_PROJECT_WORKSPACE_ROOT"]?.trim();
|
||||
if (databaseUrl === undefined || databaseUrl === "") throw new Error("DATABASE_URL is required");
|
||||
if (workspaceRoot === undefined || workspaceRoot === "") throw new Error("HUB_PROJECT_WORKSPACE_ROOT is required");
|
||||
const workspace = await realpath(workspaceRoot);
|
||||
if (!(await stat(workspace)).isDirectory()) throw new Error("restored workspace root is not a directory");
|
||||
const secrets = new LocalSecretEnvelope(await loadLocalSecretKeyringFile(keyringFile));
|
||||
const prisma = new PrismaClient({ datasources: { db: { url: databaseUrl } }, log: [] });
|
||||
try {
|
||||
const organization = await requireSiloOrganization(prisma, readSiloOrganizationId());
|
||||
const provider = await verifyStoredProviderEnvelopes(prisma, secrets);
|
||||
const feishu = await verifyStoredFeishuApplicationEnvelopes(prisma, secrets);
|
||||
const [activeProviders, activeFeishu] = await Promise.all([
|
||||
prisma.organizationProviderConnection.count({
|
||||
where: { organizationId: organization.id, status: "ACTIVE", activeSecretVersionId: { not: null } },
|
||||
}),
|
||||
prisma.organizationFeishuApplicationConnection.count({
|
||||
where: { organizationId: organization.id, status: "ACTIVE", activeSecretVersionId: { not: null } },
|
||||
}),
|
||||
]);
|
||||
if (activeProviders < 1) throw new Error("restored Silo has no ACTIVE Provider Connection");
|
||||
if (activeFeishu !== 1) throw new Error(`restored Silo must have one ACTIVE Feishu Connection; found ${activeFeishu}`);
|
||||
console.log(JSON.stringify({ organizationId: organization.id, providerEnvelopes: provider, feishuEnvelopes: feishu }));
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(argv: readonly string[]): string {
|
||||
if (argv.length !== 2 || argv[0] !== "--keyring-file" || argv[1] === undefined || argv[1] === "") {
|
||||
throw new Error("usage: restore-preflight --keyring-file /root/recovery/secret-keyring.json");
|
||||
}
|
||||
return argv[1];
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error(`[restore-preflight] failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -5,7 +5,6 @@ import { PrismaClient } from "@prisma/client";
|
||||
import { rotateLocalKek } from "../security/localKekRotation.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const SERVICE_UNIT = "cph-hub.service";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
if (process.getuid?.() !== 0) {
|
||||
@@ -30,22 +29,31 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
async function requireStoppedService(): Promise<void> {
|
||||
const serviceUnit = readServiceUnit();
|
||||
let activeState: string;
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
"systemctl",
|
||||
["show", "--property=ActiveState", "--value", SERVICE_UNIT],
|
||||
["show", "--property=ActiveState", "--value", serviceUnit],
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
activeState = stdout.trim();
|
||||
} catch {
|
||||
throw new Error(`cannot verify that ${SERVICE_UNIT} is stopped`);
|
||||
throw new Error(`cannot verify that ${serviceUnit} is stopped`);
|
||||
}
|
||||
if (activeState !== "inactive" && activeState !== "failed") {
|
||||
throw new Error(`${SERVICE_UNIT} must be stopped before local KEK rotation; state=${activeState || "unknown"}`);
|
||||
throw new Error(`${serviceUnit} must be stopped before local KEK rotation; state=${activeState || "unknown"}`);
|
||||
}
|
||||
}
|
||||
|
||||
function readServiceUnit(): string {
|
||||
const serviceUnit = process.env["HUB_SYSTEMD_UNIT"]?.trim();
|
||||
if (serviceUnit === undefined || !/^cph-hub-[a-z0-9][a-z0-9-]*\.service$/.test(serviceUnit)) {
|
||||
throw new Error("HUB_SYSTEMD_UNIT must name this Silo unit (cph-hub-<instance>.service)");
|
||||
}
|
||||
return serviceUnit;
|
||||
}
|
||||
|
||||
function parseKeyringFile(argv: readonly string[]): string {
|
||||
if (argv.length !== 2 || argv[0] !== "--keyring-file" || argv[1] === undefined || argv[1] === "") {
|
||||
throw new Error("usage: rotate-secret-kek --keyring-file /absolute/path/to/secret-keyring.json");
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
|
||||
export interface SiloOrganization {
|
||||
readonly id: string;
|
||||
readonly slug: string;
|
||||
readonly name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the deployment-pinned Organization identity. A Silo process must never
|
||||
* infer its tenant from request data or from whichever row happens to exist.
|
||||
*/
|
||||
export function readSiloOrganizationId(
|
||||
env: Readonly<Record<string, string | undefined>> = process.env,
|
||||
): string {
|
||||
const organizationId = env["HUB_SILO_ORGANIZATION_ID"]?.trim();
|
||||
if (organizationId === undefined || organizationId === "") {
|
||||
throw new Error("HUB_SILO_ORGANIZATION_ID is required");
|
||||
}
|
||||
return organizationId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prove the database is a single-tenant Silo before accepting traffic.
|
||||
* Archived rows count too: pointing a Silo process at a pooled or reused
|
||||
* database is a deployment error, not a condition to paper over.
|
||||
*/
|
||||
export async function requireSiloOrganization(
|
||||
prisma: PrismaClient,
|
||||
organizationId: string,
|
||||
): Promise<SiloOrganization> {
|
||||
const [count, organization] = await Promise.all([
|
||||
prisma.organization.count(),
|
||||
prisma.organization.findUnique({
|
||||
where: { id: organizationId },
|
||||
select: { id: true, slug: true, name: true, status: true },
|
||||
}),
|
||||
]);
|
||||
if (count !== 1) {
|
||||
throw new Error(`Silo database must contain exactly one Organization; found ${count}`);
|
||||
}
|
||||
if (organization === null) {
|
||||
throw new Error(
|
||||
`Silo Organization mismatch: configured ${organizationId} is not the sole database Organization`,
|
||||
);
|
||||
}
|
||||
if (organization.status !== "ACTIVE") {
|
||||
throw new Error(`Silo Organization ${organizationId} is ${organization.status}`);
|
||||
}
|
||||
return { id: organization.id, slug: organization.slug, name: organization.name };
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export class SiloFixedWindowRateLimiter {
|
||||
private windowStartedAt: number;
|
||||
private used = 0;
|
||||
|
||||
constructor(readonly limit: number, readonly windowMs: number, now = Date.now()) {
|
||||
if (!Number.isSafeInteger(limit) || limit <= 0) throw new Error("rate limit must be a positive integer");
|
||||
if (!Number.isSafeInteger(windowMs) || windowMs <= 0) throw new Error("rate window must be a positive integer");
|
||||
this.windowStartedAt = now;
|
||||
}
|
||||
|
||||
consume(now = Date.now()): { readonly allowed: true } | { readonly allowed: false; readonly retryAfterSeconds: number } {
|
||||
if (now >= this.windowStartedAt + this.windowMs) {
|
||||
this.windowStartedAt = now;
|
||||
this.used = 0;
|
||||
}
|
||||
if (this.used >= this.limit) {
|
||||
return {
|
||||
allowed: false,
|
||||
retryAfterSeconds: Math.max(1, Math.ceil((this.windowStartedAt + this.windowMs - now) / 1000)),
|
||||
};
|
||||
}
|
||||
this.used += 1;
|
||||
return { allowed: true };
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ export interface FeishuConfig {
|
||||
export interface FeishuRuntime {
|
||||
readonly client: lark.Client;
|
||||
readonly logger: FastifyBaseLogger;
|
||||
readonly isListenerReady?: () => boolean;
|
||||
}
|
||||
|
||||
export interface OutboundPayload {
|
||||
@@ -494,6 +495,7 @@ export async function downloadMessageFile(
|
||||
workspaceDir: string,
|
||||
workspaceRelativePath: string,
|
||||
resourceType: "image" | "file",
|
||||
maxBytes?: number,
|
||||
): Promise<string> {
|
||||
try {
|
||||
return await withRetry(async () => {
|
||||
@@ -509,6 +511,7 @@ export async function downloadMessageFile(
|
||||
workspaceDir,
|
||||
workspaceRelativePath,
|
||||
source,
|
||||
maxBytes,
|
||||
);
|
||||
} catch (error) {
|
||||
source.destroy();
|
||||
@@ -911,20 +914,41 @@ export function createLarkClient(config: FeishuConfig): lark.Client {
|
||||
});
|
||||
}
|
||||
|
||||
export function startFeishuListenerWithClient(
|
||||
export async function startFeishuListenerWithClient(
|
||||
config: FeishuConfig,
|
||||
client: lark.Client,
|
||||
logger: FastifyBaseLogger,
|
||||
onMessage: (event: MessageReceiveEvent, rt: FeishuRuntime) => Promise<void>,
|
||||
onCardAction?: (event: CardActionEvent, rt: FeishuRuntime) => Promise<void>,
|
||||
): FeishuRuntime {
|
||||
onTerminalError?: (error: Error) => void,
|
||||
): Promise<FeishuRuntime> {
|
||||
let state: "STARTING" | "READY" | "FAILED" = "STARTING";
|
||||
let resolveReady!: () => void;
|
||||
let rejectReady!: (error: Error) => void;
|
||||
const ready = new Promise<void>((resolve, reject) => {
|
||||
resolveReady = resolve;
|
||||
rejectReady = reject;
|
||||
});
|
||||
const wsClient = new lark.WSClient({
|
||||
appId: config.appId,
|
||||
appSecret: config.appSecret,
|
||||
domain: lark.Domain.Feishu,
|
||||
loggerLevel: lark.LoggerLevel.info,
|
||||
handshakeTimeoutMs: 10_000,
|
||||
onReady: () => {
|
||||
state = "READY";
|
||||
resolveReady();
|
||||
logger.info("Feishu listener ready");
|
||||
},
|
||||
onError: (error) => {
|
||||
const wasStarting = state === "STARTING";
|
||||
state = "FAILED";
|
||||
logger.error({ err: error }, "Feishu listener terminal failure");
|
||||
if (wasStarting) rejectReady(error);
|
||||
else onTerminalError?.(error);
|
||||
},
|
||||
});
|
||||
const rt: FeishuRuntime = { client, logger };
|
||||
const rt: FeishuRuntime = { client, logger, isListenerReady: () => state === "READY" };
|
||||
const handlers: Record<string, (data: unknown) => Promise<void>> = {
|
||||
"im.message.receive_v1": async (data) => {
|
||||
try { await onMessage(data as MessageReceiveEvent, rt); }
|
||||
@@ -938,15 +962,25 @@ export function startFeishuListenerWithClient(
|
||||
.catch((e) => { logger.error({ err: e }, "feishu card action handler threw"); });
|
||||
};
|
||||
}
|
||||
void wsClient.start({ eventDispatcher: new lark.EventDispatcher({}).register(handlers) });
|
||||
await wsClient.start({ eventDispatcher: new lark.EventDispatcher({}).register(handlers) });
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const startupTimeout = new Promise<never>((_resolve, reject) => {
|
||||
timeout = setTimeout(() => reject(new Error("Feishu listener did not become ready within 15 seconds")), 15_000);
|
||||
timeout.unref();
|
||||
});
|
||||
try {
|
||||
await Promise.race([ready, startupTimeout]);
|
||||
} finally {
|
||||
if (timeout !== undefined) clearTimeout(timeout);
|
||||
}
|
||||
return rt;
|
||||
}
|
||||
|
||||
export function startFeishuListener(
|
||||
export async function startFeishuListener(
|
||||
config: FeishuConfig,
|
||||
logger: FastifyBaseLogger,
|
||||
onMessage: (event: MessageReceiveEvent, rt: FeishuRuntime) => Promise<void>,
|
||||
onCardAction?: (event: CardActionEvent, rt: FeishuRuntime) => Promise<void>,
|
||||
): FeishuRuntime {
|
||||
): Promise<FeishuRuntime> {
|
||||
return startFeishuListenerWithClient(config, createLarkClient(config), logger, onMessage, onCardAction);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
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<ScopedFeishuIdentity> {
|
||||
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<ScopedFeishuIdentity> {
|
||||
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 lockActiveConnection(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<ScopedFeishuIdentity | null> {
|
||||
const connectionId = nonEmpty(input.connectionId, "Feishu connectionId");
|
||||
const openId = nonEmpty(input.openId, "Feishu openId");
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const connection = await lockActiveConnection(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);
|
||||
});
|
||||
}
|
||||
|
||||
async function lockActiveConnection(
|
||||
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);
|
||||
}
|
||||
@@ -37,8 +37,12 @@ export async function stageMessageResources(
|
||||
messageId: string,
|
||||
requests: readonly MessageResourceStageRequest[],
|
||||
workspaceRoot: string,
|
||||
limits?: { readonly maxFiles: number; readonly maxBytesPerFile: number },
|
||||
): Promise<StagedMessageResourceBatch | null> {
|
||||
if (requests.length === 0) return null;
|
||||
if (limits !== undefined && requests.length > limits.maxFiles) {
|
||||
throw new Error(`Feishu message has ${requests.length} resources; limit is ${limits.maxFiles}`);
|
||||
}
|
||||
const stagingBase = await requirePrivateStagingBase(workspaceRoot);
|
||||
const stagingRoot = join(stagingBase, randomUUID());
|
||||
const resources: StagedMessageResource[] = [];
|
||||
@@ -53,6 +57,7 @@ export async function stageMessageResources(
|
||||
stagingRoot,
|
||||
`resource-${index}`,
|
||||
request.resourceType,
|
||||
limits?.maxBytesPerFile,
|
||||
);
|
||||
resources.push({
|
||||
resourceType: request.resourceType,
|
||||
|
||||
+132
-11
@@ -68,6 +68,7 @@ import {
|
||||
type OnboardingProjectOption,
|
||||
type ProjectOnboardingActionValue,
|
||||
} from "./projectOnboardingCard.js";
|
||||
import { SiloFixedWindowRateLimiter } from "../deployment/siloRateLimit.js";
|
||||
|
||||
export { ApprovalManager } from "./approval.js";
|
||||
export type { ApprovalResult, PendingApproval } from "./approval.js";
|
||||
@@ -84,6 +85,15 @@ interface TriggerDeps {
|
||||
readonly messageBatcherOptions?: MessageBatcherOptions | undefined;
|
||||
readonly triggerQueue?: TriggerQueue | undefined;
|
||||
readonly projectWorkspaceRoot: string;
|
||||
/** Alpha Silo policy: never acknowledge work into the process-local queue. */
|
||||
readonly rejectWhenBusy?: boolean | undefined;
|
||||
readonly resourceLimits?: {
|
||||
readonly maxFilesPerMessage: number;
|
||||
readonly maxBytesPerFile: number;
|
||||
} | undefined;
|
||||
readonly allowLegacyFeishuIdentity?: boolean | undefined;
|
||||
/** Alpha Silo aggregate ingress ceiling across message and card events. */
|
||||
readonly maxFeishuEventsPerMinute?: number | undefined;
|
||||
}
|
||||
|
||||
interface TriggerActor {
|
||||
@@ -102,6 +112,13 @@ interface TriggerRunContext {
|
||||
|
||||
type StartAgentRunOutcome = "started" | "queued" | "rejected" | "locked" | "skipped";
|
||||
|
||||
class InstanceCapacityExhaustedError extends Error {
|
||||
constructor(readonly limit: number) {
|
||||
super(`Silo Agent capacity exhausted (${limit} active run(s))`);
|
||||
this.name = "InstanceCapacityExhaustedError";
|
||||
}
|
||||
}
|
||||
|
||||
export interface TriggerHandler {
|
||||
(event: MessageReceiveEvent, rt: FeishuRuntime): Promise<void>;
|
||||
readonly onCardAction: (event: CardActionEvent, rt: FeishuRuntime) => Promise<void>;
|
||||
@@ -120,6 +137,9 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
const runAgent = deps.runAgent ?? defaultRunAgent;
|
||||
const approvalManager = new ApprovalManager();
|
||||
const triggerQueue = deps.triggerQueue ?? defaultTriggerQueue;
|
||||
const feishuEventLimiter = deps.maxFeishuEventsPerMinute === undefined
|
||||
? undefined
|
||||
: new SiloFixedWindowRateLimiter(deps.maxFeishuEventsPerMinute, 60_000);
|
||||
const slashCommands = createSlashCommandRegistry({
|
||||
prisma: deps.prisma,
|
||||
settings: deps.settings,
|
||||
@@ -162,6 +182,10 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
// ADR-0002: if locked by another run, enqueue the trigger for this project.
|
||||
const existing = await currentLockRunId(deps.prisma, projectId);
|
||||
if (existing !== null) {
|
||||
if (deps.rejectWhenBusy === true) {
|
||||
await sendText(rt, chatId, "当前项目正在处理中,请稍后重试。", sendOptions);
|
||||
return "rejected";
|
||||
}
|
||||
if (!queueIfLocked) return "locked";
|
||||
return enqueueLockedTrigger(context, cleanPrompt);
|
||||
}
|
||||
@@ -224,7 +248,12 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
senderOpenId,
|
||||
});
|
||||
const senderMetadata = await senderAuditMetadata(rt, senderOpenId);
|
||||
const stagedResources = await stageTriggerMessageResources(rt, msg, projectWorkspaceRoot);
|
||||
const stagedResources = await stageTriggerMessageResources(
|
||||
rt,
|
||||
msg,
|
||||
projectWorkspaceRoot,
|
||||
deps.resourceLimits,
|
||||
);
|
||||
const agentPrompt = appendStagedResourcePaths(parsedAgentPrompt, stagedResources, project.workspaceDir);
|
||||
const promptForAgent = withFeishuTriggerContext(agentPrompt, feishuTriggerContext);
|
||||
|
||||
@@ -239,6 +268,11 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
try {
|
||||
admission = await deps.prisma.$transaction(async (tx) => {
|
||||
await lockActiveOrganization(tx, roleDecision.organizationId);
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext('cph-alpha-silo-agent-capacity'))`;
|
||||
const activeRuns = await tx.agentRun.count({ where: { status: "ACTIVE" } });
|
||||
if (activeRuns >= runPolicy.maxConcurrentRuns) {
|
||||
throw new InstanceCapacityExhaustedError(runPolicy.maxConcurrentRuns);
|
||||
}
|
||||
|
||||
// ADR-0017: provider+role+model-bound session. Reuse if one exists for
|
||||
// this tuple; otherwise create. Role remains part of the key.
|
||||
@@ -299,9 +333,21 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
await sendText(rt, chatId, "组织当前不可用,拒绝触发。", sendOptions);
|
||||
return "skipped";
|
||||
}
|
||||
if (error instanceof InstanceCapacityExhaustedError) {
|
||||
deps.logger.info(
|
||||
{ projectId, maxConcurrentRuns: error.limit },
|
||||
"feishu trigger: Silo Agent capacity exhausted",
|
||||
);
|
||||
await sendText(rt, chatId, "当前组织处理任务已满,请稍后重试。", sendOptions);
|
||||
return "rejected";
|
||||
}
|
||||
if (!isPrismaUniqueConstraintError(error)) throw error;
|
||||
const current = await currentLockRunId(deps.prisma, projectId);
|
||||
if (current === null) throw error;
|
||||
if (deps.rejectWhenBusy === true) {
|
||||
await sendText(rt, chatId, "当前项目正在处理中,请稍后重试。", sendOptions);
|
||||
return "rejected";
|
||||
}
|
||||
if (!queueIfLocked) return "locked";
|
||||
return enqueueLockedTrigger(context, cleanPrompt);
|
||||
}
|
||||
@@ -409,6 +455,12 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
// Abort handle for this run; the interrupt card action calls abort() to
|
||||
// stop the SDK query. Cleared in the finally below.
|
||||
const abortController = new AbortController();
|
||||
let wallTimeExceeded = false;
|
||||
const wallTimeTimer = setTimeout(() => {
|
||||
wallTimeExceeded = true;
|
||||
abortController.abort("run_wall_time_exceeded");
|
||||
}, runPolicy.maxRunSeconds * 1000);
|
||||
wallTimeTimer.unref();
|
||||
activeRuns.set(run.id, abortController);
|
||||
const agentExecution = (async () => {
|
||||
const providerLease = await provider.openAgentLease({ runId: run.id });
|
||||
@@ -477,7 +529,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
})();
|
||||
agentExecution
|
||||
.then(async (result) => {
|
||||
const interrupted = result.status === "interrupted";
|
||||
const interrupted = result.status === "interrupted" && !wallTimeExceeded;
|
||||
const finalText =
|
||||
result.text !== ""
|
||||
? result.text
|
||||
@@ -495,7 +547,15 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
await deps.prisma.agentRun.update({
|
||||
where: { id: run.id },
|
||||
data: {
|
||||
status: interrupted ? "CANCELED" : result.status === "completed" ? "COMPLETED" : result.status === "length" ? "TIMED_OUT" : "FAILED",
|
||||
status: wallTimeExceeded
|
||||
? "TIMED_OUT"
|
||||
: interrupted
|
||||
? "CANCELED"
|
||||
: result.status === "completed"
|
||||
? "COMPLETED"
|
||||
: result.status === "length"
|
||||
? "TIMED_OUT"
|
||||
: "FAILED",
|
||||
inputTokens: result.usage.inputTokens,
|
||||
outputTokens: result.usage.outputTokens,
|
||||
...(result.costUsd !== undefined ? { costUsd: result.costUsd, costSource: "provider_reported" } : {}),
|
||||
@@ -528,6 +588,7 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
await writeAudit(deps.prisma, { runId: run.id, projectId, action: "run.failed", metadata: { error: String(e) } });
|
||||
})
|
||||
.finally(async () => {
|
||||
clearTimeout(wallTimeTimer);
|
||||
try {
|
||||
await releaseLock(deps.prisma, run.id);
|
||||
} catch (e) {
|
||||
@@ -621,6 +682,18 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
}
|
||||
|
||||
const onCardAction = async (event: CardActionEvent, rt: FeishuRuntime): Promise<void> => {
|
||||
const rateDecision = feishuEventLimiter?.consume();
|
||||
if (rateDecision !== undefined && !rateDecision.allowed) {
|
||||
deps.logger.warn(
|
||||
{
|
||||
retryAfterSeconds: rateDecision.retryAfterSeconds,
|
||||
chatId: nonEmpty(event.context?.open_chat_id),
|
||||
operatorOpenId: nonEmpty(event.operator.open_id),
|
||||
},
|
||||
"feishu trigger: Silo event rate exceeded; card action rejected",
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Approval buttons (file-delivery / explicit approval cards).
|
||||
const approvalAction = approvalActionFromValue(event.action.value);
|
||||
if (approvalAction !== null) {
|
||||
@@ -803,6 +876,28 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
});
|
||||
}
|
||||
|
||||
const rateDecision = feishuEventLimiter?.consume();
|
||||
if (rateDecision !== undefined && !rateDecision.allowed) {
|
||||
deps.logger.warn(
|
||||
{
|
||||
retryAfterSeconds: rateDecision.retryAfterSeconds,
|
||||
chatId: msg.chat_id,
|
||||
messageId: msg.message_id,
|
||||
senderOpenId: event.sender.sender_id.open_id,
|
||||
},
|
||||
"feishu trigger: Silo event rate exceeded; message rejected",
|
||||
);
|
||||
if (msg.chat_id !== "") {
|
||||
await sendText(
|
||||
rt,
|
||||
msg.chat_id,
|
||||
`当前服务请求过多,请约 ${rateDecision.retryAfterSeconds} 秒后重试。`,
|
||||
sendOptionsForTriggerMessage(msg),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Only react to supported inbound message types in group chats.
|
||||
if (msg.message_type !== "text" && msg.message_type !== "file" && msg.message_type !== "image" && msg.message_type !== "post") return;
|
||||
const chatId = msg.chat_id;
|
||||
@@ -913,6 +1008,10 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
if (msg.message_type === "text") {
|
||||
const existing = await currentLockRunId(deps.prisma, projectId);
|
||||
if (existing !== null) {
|
||||
if (deps.rejectWhenBusy === true) {
|
||||
await sendText(rt, chatId, "当前项目正在处理中,请稍后重试。", sendOptionsForTriggerMessage(msg));
|
||||
return;
|
||||
}
|
||||
await enqueueLockedTrigger(runContext, cleanPrompt);
|
||||
return;
|
||||
}
|
||||
@@ -975,10 +1074,13 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
}
|
||||
| { readonly status: "error"; readonly message: string }
|
||||
> {
|
||||
const user = await deps.prisma.user.findUnique({
|
||||
where: { feishuOpenId },
|
||||
const identity = await deps.prisma.feishuUserIdentity.findFirst({
|
||||
where: {
|
||||
openId: feishuOpenId,
|
||||
connection: { status: "ACTIVE", organization: { status: "ACTIVE" } },
|
||||
},
|
||||
select: {
|
||||
organizationMemberships: {
|
||||
user: { select: { organizationMemberships: {
|
||||
where: {
|
||||
revokedAt: null,
|
||||
organization: { status: "ACTIVE" },
|
||||
@@ -988,12 +1090,22 @@ export function makeTriggerHandler(deps: TriggerDeps): TriggerHandler {
|
||||
organization: { select: { id: true, name: true } },
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
},
|
||||
} } },
|
||||
},
|
||||
});
|
||||
if (user === null) {
|
||||
return { status: "error", message: "请先登录并加入组织后,再绑定项目。" };
|
||||
}
|
||||
const user = identity?.user ?? (deps.allowLegacyFeishuIdentity === true
|
||||
? await deps.prisma.user.findUnique({
|
||||
where: { feishuOpenId },
|
||||
select: {
|
||||
organizationMemberships: {
|
||||
where: { revokedAt: null, organization: { status: "ACTIVE" } },
|
||||
select: { role: true, organization: { select: { id: true, name: true } } },
|
||||
orderBy: { createdAt: "asc" },
|
||||
},
|
||||
},
|
||||
})
|
||||
: null);
|
||||
if (user === null) return { status: "error", message: "请先登录并加入组织后,再绑定项目。" };
|
||||
if (user.organizationMemberships.length === 0) {
|
||||
return { status: "error", message: "你还不属于任何可用组织,请联系组织管理员。" };
|
||||
}
|
||||
@@ -1378,6 +1490,7 @@ async function stageTriggerMessageResources(
|
||||
rt: FeishuRuntime,
|
||||
msg: MessageReceiveEvent["message"],
|
||||
workspaceRoot: string,
|
||||
limits?: TriggerDeps["resourceLimits"],
|
||||
): Promise<StagedMessageResourceBatch | null> {
|
||||
const requests: MessageResourceStageRequest[] = [];
|
||||
if (msg.message_type === "post") {
|
||||
@@ -1407,7 +1520,15 @@ async function stageTriggerMessageResources(
|
||||
});
|
||||
}
|
||||
}
|
||||
return stageMessageResources(rt, msg.message_id, requests, workspaceRoot);
|
||||
return stageMessageResources(
|
||||
rt,
|
||||
msg.message_id,
|
||||
requests,
|
||||
workspaceRoot,
|
||||
limits === undefined
|
||||
? undefined
|
||||
: { maxFiles: limits.maxFilesPerMessage, maxBytesPerFile: limits.maxBytesPerFile },
|
||||
);
|
||||
}
|
||||
|
||||
function appendStagedResourcePaths(
|
||||
|
||||
+96
-13
@@ -10,7 +10,10 @@ import { createDatabaseRuntimeSettings } from "./settings/runtime.js";
|
||||
import { verifyStoredProviderEnvelopes } from "./connections/providerConnections.js";
|
||||
import { openProviderProxyLease } from "./connections/providerProxy.js";
|
||||
import { verifyStoredFeishuApplicationEnvelopes } from "./connections/feishuApplicationConnections.js";
|
||||
import { resolveActiveFeishuApplication } from "./connections/feishuApplicationConnections.js";
|
||||
import { readServerBinding } from "./settings/server.js";
|
||||
import { readSiloOrganizationId, requireSiloOrganization } from "./deployment/silo.js";
|
||||
import { SiloFixedWindowRateLimiter } from "./deployment/siloRateLimit.js";
|
||||
|
||||
function requireEnv(name: string): string {
|
||||
const value = process.env[name];
|
||||
@@ -30,7 +33,23 @@ function booleanEnv(name: string, fallback: boolean): boolean {
|
||||
export async function startHub(): Promise<void> {
|
||||
requireEnv("DATABASE_URL");
|
||||
const projectWorkspaceRoot = requireEnv("HUB_PROJECT_WORKSPACE_ROOT");
|
||||
const app = Fastify({ logger: true });
|
||||
const httpBodyLimit = positiveIntegerEnv("HUB_HTTP_BODY_LIMIT_BYTES");
|
||||
const maxFilesPerMessage = positiveIntegerEnv("HUB_MAX_FILES_PER_MESSAGE");
|
||||
const maxBytesPerFile = positiveIntegerEnv("HUB_MAX_FILE_BYTES");
|
||||
const httpRequestsPerMinute = positiveIntegerEnv("HUB_HTTP_REQUESTS_PER_MINUTE");
|
||||
const feishuEventsPerMinute = positiveIntegerEnv("HUB_FEISHU_EVENTS_PER_MINUTE");
|
||||
const app = Fastify({ logger: true, bodyLimit: httpBodyLimit });
|
||||
const requestLimiter = new SiloFixedWindowRateLimiter(httpRequestsPerMinute, 60_000);
|
||||
app.addHook("onRequest", async (request, reply) => {
|
||||
if (request.url === "/api/healthz") return;
|
||||
const decision = requestLimiter.consume();
|
||||
if (!decision.allowed) {
|
||||
await reply
|
||||
.header("Retry-After", String(decision.retryAfterSeconds))
|
||||
.status(429)
|
||||
.send({ error: { code: "rate_limited", message: "Silo request rate exceeded" } });
|
||||
}
|
||||
});
|
||||
|
||||
const abandonedStages = await removeAbandonedMessageResourceStages(projectWorkspaceRoot);
|
||||
app.log.info({ removed: abandonedStages }, "startup: removed abandoned Feishu resource stages");
|
||||
@@ -59,9 +78,39 @@ export async function startHub(): Promise<void> {
|
||||
}),
|
||||
);
|
||||
|
||||
const feishuAppId = requireEnv("FEISHU_APP_ID");
|
||||
const feishuAppSecret = requireEnv("FEISHU_APP_SECRET");
|
||||
const feishuBotOpenId = requireEnv("FEISHU_BOT_OPEN_ID");
|
||||
const siloOrganizationId = readSiloOrganizationId();
|
||||
const siloOrganization = await requireSiloOrganization(prisma, siloOrganizationId);
|
||||
const activeProvider = await prisma.organizationProviderConnection.findUnique({
|
||||
where: {
|
||||
organizationId_providerId: {
|
||||
organizationId: siloOrganization.id,
|
||||
providerId: "openrouter",
|
||||
},
|
||||
},
|
||||
select: {
|
||||
status: true,
|
||||
activeSecretVersion: { select: { connectionId: true, retiredAt: true } },
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
if (activeProvider?.status !== "ACTIVE" || activeProvider.activeSecretVersion === null ||
|
||||
activeProvider.activeSecretVersion.connectionId !== activeProvider.id ||
|
||||
activeProvider.activeSecretVersion.retiredAt !== null) {
|
||||
throw new Error(`Silo Organization ${siloOrganization.id} has no ACTIVE openrouter Provider Connection`);
|
||||
}
|
||||
const feishuApplication = await resolveActiveFeishuApplication(
|
||||
prisma,
|
||||
secretEnvelope,
|
||||
{ organizationId: siloOrganization.id },
|
||||
);
|
||||
app.log.info(
|
||||
{
|
||||
organizationId: siloOrganization.id,
|
||||
organizationSlug: siloOrganization.slug,
|
||||
feishuConnectionId: feishuApplication.connectionId,
|
||||
},
|
||||
"startup: proved Silo Organization and resolved Feishu Application",
|
||||
);
|
||||
const sessionSecret = requireEnv("HUB_SESSION_SECRET");
|
||||
const publicBaseUrl = process.env["HUB_PUBLIC_BASE_URL"] ?? "http://127.0.0.1:8788";
|
||||
const bind = readServerBinding();
|
||||
@@ -74,24 +123,30 @@ export async function startHub(): Promise<void> {
|
||||
});
|
||||
app.log.info("startup: cleared stale locks + dead runs");
|
||||
|
||||
app.get("/api/healthz", async () => ({ ok: true, ts: Date.now() }));
|
||||
let feishuRuntime: { readonly isListenerReady?: () => boolean } | undefined;
|
||||
app.get("/api/healthz", async (_request, reply) => {
|
||||
const feishuReady = feishuRuntime?.isListenerReady?.() ?? !booleanEnv("HUB_FEISHU_LISTENER_ENABLED", true);
|
||||
if (!feishuReady) return reply.status(503).send({ ok: false, feishuReady, ts: Date.now() });
|
||||
return { ok: true, feishuReady, ts: Date.now() };
|
||||
});
|
||||
|
||||
await registerAdminPlugin(app, {
|
||||
prisma,
|
||||
sessionSecret,
|
||||
publicBaseUrl,
|
||||
feishuAppId,
|
||||
feishuAppSecret,
|
||||
feishuAppId: feishuApplication.appId,
|
||||
feishuAppSecret: feishuApplication.appSecret,
|
||||
projectWorkspaceRoot,
|
||||
secretEnvelope,
|
||||
});
|
||||
|
||||
const address = await app.listen(bind);
|
||||
app.log.info({ address }, "hub listening");
|
||||
|
||||
const feishuListenerEnabled = booleanEnv("HUB_FEISHU_LISTENER_ENABLED", true);
|
||||
if (feishuListenerEnabled) {
|
||||
const feishuConfig = { appId: feishuAppId, appSecret: feishuAppSecret, botOpenId: feishuBotOpenId };
|
||||
const feishuConfig = {
|
||||
appId: feishuApplication.appId,
|
||||
appSecret: feishuApplication.appSecret,
|
||||
botOpenId: feishuApplication.botOpenId,
|
||||
};
|
||||
const larkClient = createLarkClient(feishuConfig);
|
||||
const triggerQueuePurgeTimer = setInterval(() => {
|
||||
const removed = triggerQueue.purgeExpired();
|
||||
@@ -100,9 +155,37 @@ export async function startHub(): Promise<void> {
|
||||
}
|
||||
}, 60_000);
|
||||
triggerQueuePurgeTimer.unref();
|
||||
const trigger = makeTriggerHandler({ prisma, settings: runtimeSettings, logger: app.log, projectWorkspaceRoot });
|
||||
startFeishuListenerWithClient(feishuConfig, larkClient, app.log, trigger, trigger.onCardAction);
|
||||
const trigger = makeTriggerHandler({
|
||||
prisma,
|
||||
settings: runtimeSettings,
|
||||
logger: app.log,
|
||||
projectWorkspaceRoot,
|
||||
rejectWhenBusy: true,
|
||||
resourceLimits: { maxFilesPerMessage, maxBytesPerFile },
|
||||
allowLegacyFeishuIdentity: false,
|
||||
maxFeishuEventsPerMinute: feishuEventsPerMinute,
|
||||
});
|
||||
feishuRuntime = await startFeishuListenerWithClient(
|
||||
feishuConfig,
|
||||
larkClient,
|
||||
app.log,
|
||||
trigger,
|
||||
trigger.onCardAction,
|
||||
() => {
|
||||
process.exitCode = 1;
|
||||
void app.close().catch((error) => app.log.error({ err: error }, "Hub close after Feishu failure failed"));
|
||||
},
|
||||
);
|
||||
} else {
|
||||
app.log.info("feishu listener disabled by HUB_FEISHU_LISTENER_ENABLED");
|
||||
}
|
||||
const address = await app.listen(bind);
|
||||
app.log.info({ address }, "hub listening");
|
||||
}
|
||||
|
||||
function positiveIntegerEnv(name: string): number {
|
||||
const raw = requireEnv(name);
|
||||
const value = Number(raw);
|
||||
if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${name} must be a positive safe integer`);
|
||||
return value;
|
||||
}
|
||||
|
||||
+54
-24
@@ -8,11 +8,13 @@
|
||||
* 4. ADMIN cannot modify OWNER memberships.
|
||||
*/
|
||||
import type { OrganizationMemberRole, Prisma, PrismaClient } from "@prisma/client";
|
||||
import { upsertScopedFeishuIdentityInTransaction } from "../feishu/identityNamespace.js";
|
||||
import { lockActiveOrganization } from "./status.js";
|
||||
|
||||
export interface OrgMemberRow {
|
||||
readonly userId: string;
|
||||
readonly feishuOpenId: string;
|
||||
readonly feishuOpenId: string | null;
|
||||
readonly identityStatus: "SCOPED" | "UNLINKED";
|
||||
readonly displayName: string;
|
||||
readonly avatarUrl: string | null;
|
||||
readonly role: OrganizationMemberRole;
|
||||
@@ -29,14 +31,24 @@ export async function listOrgMembers(
|
||||
role: true,
|
||||
createdAt: true,
|
||||
user: {
|
||||
select: { id: true, feishuOpenId: true, displayName: true, avatarUrl: true },
|
||||
select: {
|
||||
id: true,
|
||||
displayName: true,
|
||||
avatarUrl: true,
|
||||
feishuIdentities: {
|
||||
where: { connection: { organizationId } },
|
||||
select: { openId: true },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ role: "asc" }, { createdAt: "asc" }],
|
||||
});
|
||||
return rows.map((row) => ({
|
||||
userId: row.user.id,
|
||||
feishuOpenId: row.user.feishuOpenId,
|
||||
feishuOpenId: row.user.feishuIdentities[0]?.openId ?? null,
|
||||
identityStatus: row.user.feishuIdentities.length === 1 ? "SCOPED" : "UNLINKED",
|
||||
displayName: row.user.displayName,
|
||||
avatarUrl: row.user.avatarUrl,
|
||||
role: row.role,
|
||||
@@ -57,39 +69,47 @@ export async function addOrgMember(
|
||||
const openId = requireNonEmpty(input.feishuOpenId, "feishuOpenId");
|
||||
assertCanAssignRole(input.actorRole, input.role, /* targetIsOwner */ false);
|
||||
|
||||
const { user, membership } = await prisma.$transaction(async (tx) => {
|
||||
const { identity, membership } = await prisma.$transaction(async (tx) => {
|
||||
await lockActiveOrganization(tx, input.organizationId);
|
||||
const user = await tx.user.upsert({
|
||||
where: { feishuOpenId: openId },
|
||||
create: {
|
||||
feishuOpenId: openId,
|
||||
displayName: input.displayName?.trim() || openId,
|
||||
},
|
||||
update: {
|
||||
...(input.displayName !== undefined && input.displayName.trim() !== ""
|
||||
? { displayName: input.displayName.trim() }
|
||||
: {}),
|
||||
const connection = await tx.organizationFeishuApplicationConnection.findUnique({
|
||||
where: { organizationId: input.organizationId },
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
activeSecretVersion: { select: { connectionId: true, retiredAt: true } },
|
||||
},
|
||||
});
|
||||
if (connection === null || connection.status !== "ACTIVE" || connection.activeSecretVersion === null ||
|
||||
connection.activeSecretVersion.connectionId !== connection.id ||
|
||||
connection.activeSecretVersion.retiredAt !== null) {
|
||||
throw new Error("active Feishu Application Connection not found");
|
||||
}
|
||||
const identity = await upsertScopedFeishuIdentityInTransaction(tx, {
|
||||
connectionId: connection.id,
|
||||
expectedOrganizationId: input.organizationId,
|
||||
openId,
|
||||
displayName: input.displayName?.trim() || openId,
|
||||
});
|
||||
const existing = await tx.organizationMembership.findFirst({
|
||||
where: { organizationId: input.organizationId, userId: user.id, revokedAt: null },
|
||||
where: { organizationId: input.organizationId, userId: identity.userId, revokedAt: null },
|
||||
});
|
||||
if (existing !== null) throw new Error(`user ${user.id} is already an active member`);
|
||||
if (existing !== null) throw new Error(`user ${identity.userId} is already an active member`);
|
||||
const membership = await tx.organizationMembership.create({
|
||||
data: {
|
||||
organizationId: input.organizationId,
|
||||
userId: user.id,
|
||||
userId: identity.userId,
|
||||
role: input.role,
|
||||
},
|
||||
});
|
||||
return { user, membership };
|
||||
return { identity, membership };
|
||||
});
|
||||
|
||||
return {
|
||||
userId: user.id,
|
||||
feishuOpenId: user.feishuOpenId,
|
||||
displayName: user.displayName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
userId: identity.userId,
|
||||
feishuOpenId: identity.openId,
|
||||
identityStatus: "SCOPED",
|
||||
displayName: identity.displayName,
|
||||
avatarUrl: identity.avatarUrl,
|
||||
role: membership.role,
|
||||
createdAt: membership.createdAt.toISOString(),
|
||||
};
|
||||
@@ -118,7 +138,16 @@ export async function setOrgMemberRole(
|
||||
role: true,
|
||||
createdAt: true,
|
||||
user: {
|
||||
select: { id: true, feishuOpenId: true, displayName: true, avatarUrl: true },
|
||||
select: {
|
||||
id: true,
|
||||
displayName: true,
|
||||
avatarUrl: true,
|
||||
feishuIdentities: {
|
||||
where: { connection: { organizationId: input.organizationId } },
|
||||
select: { openId: true },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -139,7 +168,8 @@ export async function setOrgMemberRole(
|
||||
|
||||
return {
|
||||
userId: membership.user.id,
|
||||
feishuOpenId: membership.user.feishuOpenId,
|
||||
feishuOpenId: membership.user.feishuIdentities[0]?.openId ?? null,
|
||||
identityStatus: membership.user.feishuIdentities.length === 1 ? "SCOPED" : "UNLINKED",
|
||||
displayName: membership.user.displayName,
|
||||
avatarUrl: membership.user.avatarUrl,
|
||||
role: updated.role,
|
||||
|
||||
+50
-15
@@ -168,16 +168,30 @@ export async function listTeamMembers(
|
||||
where: { teamId: input.teamId, revokedAt: null },
|
||||
select: {
|
||||
createdAt: true,
|
||||
user: { select: { id: true, feishuOpenId: true, displayName: true } },
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
feishuOpenId: true,
|
||||
displayName: true,
|
||||
feishuIdentities: {
|
||||
where: { connection: { organizationId: input.organizationId } },
|
||||
select: { openId: true },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
return rows.map((row) => ({
|
||||
userId: row.user.id,
|
||||
feishuOpenId: row.user.feishuOpenId,
|
||||
displayName: row.user.displayName,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
}));
|
||||
return rows.map((row) => {
|
||||
const identity = row.user.feishuIdentities[0];
|
||||
return {
|
||||
userId: row.user.id,
|
||||
feishuOpenId: identity?.openId ?? row.user.feishuOpenId,
|
||||
displayName: row.user.displayName,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export async function addTeamMember(
|
||||
@@ -244,23 +258,44 @@ export async function revokeTeamMember(
|
||||
|
||||
async function resolveUser(
|
||||
prisma: PrismaClient | Prisma.TransactionClient,
|
||||
input: { readonly userId?: string | undefined; readonly feishuOpenId?: string | undefined },
|
||||
input: {
|
||||
readonly organizationId: string;
|
||||
readonly userId?: string | undefined;
|
||||
readonly feishuOpenId?: string | undefined;
|
||||
},
|
||||
): Promise<{ readonly id: string; readonly feishuOpenId: string; readonly displayName: string }> {
|
||||
if (input.userId !== undefined && input.userId !== "") {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: input.userId },
|
||||
select: { id: true, feishuOpenId: true, displayName: true },
|
||||
select: {
|
||||
id: true,
|
||||
feishuOpenId: true,
|
||||
displayName: true,
|
||||
feishuIdentities: {
|
||||
where: { connection: { organizationId: input.organizationId } },
|
||||
select: { openId: true },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (user === null) throw new Error(`user not found: ${input.userId}`);
|
||||
return user;
|
||||
const identity = user.feishuIdentities[0];
|
||||
return {
|
||||
id: user.id,
|
||||
feishuOpenId: identity?.openId ?? user.feishuOpenId,
|
||||
displayName: user.displayName,
|
||||
};
|
||||
}
|
||||
if (input.feishuOpenId !== undefined && input.feishuOpenId !== "") {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { feishuOpenId: input.feishuOpenId },
|
||||
select: { id: true, feishuOpenId: true, displayName: true },
|
||||
const identity = await prisma.feishuUserIdentity.findFirst({
|
||||
where: {
|
||||
openId: input.feishuOpenId,
|
||||
connection: { organizationId: input.organizationId, status: "ACTIVE" },
|
||||
},
|
||||
select: { openId: true, user: { select: { id: true, displayName: true } } },
|
||||
});
|
||||
if (user === null) throw new Error(`user not found: ${input.feishuOpenId}`);
|
||||
return user;
|
||||
if (identity === null) throw new Error(`user not found in organization: ${input.feishuOpenId}`);
|
||||
return { id: identity.user.id, feishuOpenId: identity.openId, displayName: identity.user.displayName };
|
||||
}
|
||||
throw new Error("userId or feishuOpenId is required");
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PrismaClient, PrincipalType } from "@prisma/client";
|
||||
import { scopedFeishuPrincipalId } from "../feishu/identityNamespace.js";
|
||||
|
||||
export interface PrincipalRef {
|
||||
readonly type: PrincipalType;
|
||||
@@ -40,19 +41,60 @@ export interface PrincipalResolver {
|
||||
}
|
||||
|
||||
export class PrismaPrincipalResolver implements PrincipalResolver {
|
||||
constructor(private readonly prisma: PrismaClient) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly allowLegacyIdentity = process.env["NODE_ENV"] !== "production",
|
||||
) {}
|
||||
|
||||
async resolveActor(actor: ActorInput, scope: PrincipalResolutionScope): Promise<PrincipalResolution> {
|
||||
const resolved = new PrincipalCollector();
|
||||
resolved.add({ type: "USER", id: actor.feishuOpenId }, "actor-user");
|
||||
if (actor.chatId !== undefined && actor.chatId !== "") {
|
||||
resolved.add({ type: "FEISHU_CHAT", id: actor.chatId }, "context-chat");
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { feishuOpenId: actor.feishuOpenId },
|
||||
select: { id: true },
|
||||
const scopedIdentity = await this.prisma.feishuUserIdentity.findFirst({
|
||||
where: {
|
||||
openId: actor.feishuOpenId,
|
||||
connection: {
|
||||
organizationId: scope.organizationId,
|
||||
status: "ACTIVE",
|
||||
activeSecretVersion: { retiredAt: null },
|
||||
},
|
||||
},
|
||||
select: { userId: true, connectionId: true },
|
||||
});
|
||||
// Explicit migration adapter for pre-ADR-0024 fixtures/data. Alpha Silo
|
||||
// ingress always has an ACTIVE connection-scoped identity.
|
||||
const user = scopedIdentity === null && this.allowLegacyIdentity
|
||||
? await this.prisma.user.findUnique({
|
||||
where: { feishuOpenId: actor.feishuOpenId },
|
||||
select: { id: true },
|
||||
})
|
||||
: scopedIdentity === null ? null : { id: scopedIdentity.userId };
|
||||
const connection = await this.prisma.organizationFeishuApplicationConnection.findUnique({
|
||||
where: { organizationId: scope.organizationId },
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
activeSecretVersion: { select: { connectionId: true, retiredAt: true } },
|
||||
},
|
||||
});
|
||||
const activeConnectionId = scopedIdentity?.connectionId ?? (
|
||||
connection?.status === "ACTIVE" && connection.activeSecretVersion !== null &&
|
||||
connection.activeSecretVersion.connectionId === connection.id &&
|
||||
connection.activeSecretVersion.retiredAt === null
|
||||
? connection.id
|
||||
: undefined
|
||||
);
|
||||
if (activeConnectionId === undefined && !this.allowLegacyIdentity) {
|
||||
throw new Error(`active Feishu Application Connection not found for organization ${scope.organizationId}`);
|
||||
}
|
||||
const userPrincipalId = activeConnectionId === undefined
|
||||
? actor.feishuOpenId
|
||||
: scopedFeishuPrincipalId("USER", activeConnectionId, actor.feishuOpenId);
|
||||
resolved.add({ type: "USER", id: userPrincipalId }, "actor-user");
|
||||
if (actor.chatId !== undefined && actor.chatId !== "") {
|
||||
const chatPrincipalId = activeConnectionId === undefined
|
||||
? actor.chatId
|
||||
: scopedFeishuPrincipalId("CHAT", activeConnectionId, actor.chatId);
|
||||
resolved.add({ type: "FEISHU_CHAT", id: chatPrincipalId }, "context-chat");
|
||||
}
|
||||
|
||||
if (user !== null) {
|
||||
const memberships = await this.prisma.teamMembership.findMany({
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Folder, OrganizationMemberRole, PermissionRole, Prisma, PrismaClie
|
||||
import { createPermissionAuthorizer } from "./permission.js";
|
||||
import { writeAudit } from "./audit.js";
|
||||
import { lockActiveOrganization, requireActiveOrganizationStatus } from "./org/status.js";
|
||||
import { scopedFeishuPrincipalId } from "./feishu/identityNamespace.js";
|
||||
|
||||
export interface OrganizationProjectPolicy {
|
||||
readonly organizationId: string;
|
||||
@@ -140,7 +141,8 @@ export async function createProjectFromOrgAdmin(
|
||||
return createManagedProject(prisma, {
|
||||
organizationId: input.organizationId,
|
||||
actorUserId: actor.userId,
|
||||
actorFeishuOpenId: input.actorFeishuOpenId,
|
||||
actorPrincipalId: actor.principalId,
|
||||
feishuConnectionId: actor.connectionId,
|
||||
name,
|
||||
workspaceRoot: input.workspaceRoot,
|
||||
folderId: input.folderId,
|
||||
@@ -170,7 +172,8 @@ export async function createProjectFromFeishuChat(
|
||||
return createManagedProject(prisma, {
|
||||
organizationId: input.organizationId,
|
||||
actorUserId: actor.userId,
|
||||
actorFeishuOpenId: input.actorFeishuOpenId,
|
||||
actorPrincipalId: actor.principalId,
|
||||
feishuConnectionId: actor.connectionId,
|
||||
name,
|
||||
workspaceRoot: input.workspaceRoot,
|
||||
folderId: input.folderId,
|
||||
@@ -212,7 +215,7 @@ export async function bindFeishuChatToProject(
|
||||
await replaceProjectGrant(tx, {
|
||||
projectId: project.id,
|
||||
principalType: "FEISHU_CHAT",
|
||||
principalId: chatId,
|
||||
principalId: scopedChatPrincipalId(actor.connectionId, chatId),
|
||||
role: "EDIT",
|
||||
createdByUserId: actor.userId,
|
||||
});
|
||||
@@ -262,7 +265,7 @@ export async function archiveFeishuChatBinding(
|
||||
resourceType: "PROJECT",
|
||||
resourceId: binding.projectId,
|
||||
principalType: "FEISHU_CHAT",
|
||||
principalId: binding.chatId,
|
||||
principalId: scopedChatPrincipalId(actor.connectionId, binding.chatId),
|
||||
revokedAt: null,
|
||||
},
|
||||
data: { revokedAt: new Date() },
|
||||
@@ -282,7 +285,8 @@ async function createManagedProject(
|
||||
input: {
|
||||
readonly organizationId: string;
|
||||
readonly actorUserId: string;
|
||||
readonly actorFeishuOpenId: string;
|
||||
readonly actorPrincipalId: string;
|
||||
readonly feishuConnectionId: string | undefined;
|
||||
readonly name: string;
|
||||
readonly workspaceRoot: string;
|
||||
readonly folderId: string | undefined;
|
||||
@@ -332,7 +336,7 @@ async function createManagedProject(
|
||||
await replaceProjectGrant(tx, {
|
||||
projectId: created.id,
|
||||
principalType: "USER",
|
||||
principalId: input.actorFeishuOpenId,
|
||||
principalId: input.actorPrincipalId,
|
||||
role: "MANAGE",
|
||||
createdByUserId: input.actorUserId,
|
||||
});
|
||||
@@ -343,7 +347,7 @@ async function createManagedProject(
|
||||
await replaceProjectGrant(tx, {
|
||||
projectId: created.id,
|
||||
principalType: "FEISHU_CHAT",
|
||||
principalId: input.chatId,
|
||||
principalId: scopedChatPrincipalId(input.feishuConnectionId, input.chatId),
|
||||
role: "EDIT",
|
||||
createdByUserId: input.actorUserId,
|
||||
});
|
||||
@@ -385,7 +389,13 @@ async function requireProjectManager(
|
||||
projectId: string,
|
||||
organizationId: string,
|
||||
actorFeishuOpenId: string,
|
||||
): Promise<{ readonly userId: string; readonly role: OrganizationMemberRole; readonly via: "org-admin" | "project-manage" }> {
|
||||
): Promise<{
|
||||
readonly userId: string;
|
||||
readonly role: OrganizationMemberRole;
|
||||
readonly principalId: string;
|
||||
readonly connectionId: string | undefined;
|
||||
readonly via: "org-admin" | "project-manage";
|
||||
}> {
|
||||
const actor = await requireActiveOrgMember(prisma, organizationId, actorFeishuOpenId);
|
||||
if (isOrgAdminRole(actor.role)) {
|
||||
return { ...actor, via: "org-admin" };
|
||||
@@ -405,9 +415,42 @@ async function requireActiveOrgMember(
|
||||
prisma: PrismaClient,
|
||||
organizationId: string,
|
||||
feishuOpenId: string,
|
||||
): Promise<{ readonly userId: string; readonly role: OrganizationMemberRole }> {
|
||||
): Promise<{
|
||||
readonly userId: string;
|
||||
readonly role: OrganizationMemberRole;
|
||||
readonly principalId: string;
|
||||
readonly connectionId: string | undefined;
|
||||
}> {
|
||||
await requireActiveOrganization(prisma, organizationId);
|
||||
const user = await prisma.user.findUnique({
|
||||
const identity = await prisma.feishuUserIdentity.findFirst({
|
||||
where: { openId: feishuOpenId, connection: { organizationId, status: "ACTIVE" } },
|
||||
select: {
|
||||
connectionId: true,
|
||||
user: { select: {
|
||||
id: true,
|
||||
organizationMemberships: {
|
||||
where: { organizationId, revokedAt: null },
|
||||
select: { role: true },
|
||||
take: 1,
|
||||
},
|
||||
} },
|
||||
},
|
||||
});
|
||||
if (identity !== null) {
|
||||
const membership = identity.user.organizationMemberships[0];
|
||||
if (membership === undefined) {
|
||||
throw new Error(`Feishu user ${feishuOpenId} is not an active member of organization ${organizationId}`);
|
||||
}
|
||||
return {
|
||||
userId: identity.user.id,
|
||||
role: membership.role,
|
||||
principalId: scopedFeishuPrincipalId("USER", identity.connectionId, feishuOpenId),
|
||||
connectionId: identity.connectionId,
|
||||
};
|
||||
}
|
||||
// Explicit compatibility path for pre-ADR-0024 rows while the Silo branch
|
||||
// completes migration. New bootstrap/member writes always create identities.
|
||||
const user = process.env["NODE_ENV"] === "production" ? null : await prisma.user.findUnique({
|
||||
where: { feishuOpenId },
|
||||
select: {
|
||||
id: true,
|
||||
@@ -425,7 +468,11 @@ async function requireActiveOrgMember(
|
||||
if (membership === undefined) {
|
||||
throw new Error(`Feishu user ${feishuOpenId} is not an active member of organization ${organizationId}`);
|
||||
}
|
||||
return { userId: user.id, role: membership.role };
|
||||
return { userId: user.id, role: membership.role, principalId: feishuOpenId, connectionId: undefined };
|
||||
}
|
||||
|
||||
function scopedChatPrincipalId(connectionId: string | undefined, chatId: string): string {
|
||||
return connectionId === undefined ? chatId : scopedFeishuPrincipalId("CHAT", connectionId, chatId);
|
||||
}
|
||||
|
||||
async function ensureOrganizationProjectSettingsTx(
|
||||
|
||||
@@ -7,7 +7,7 @@ export class WorkspaceFileBoundaryError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly requestedPath: string,
|
||||
readonly reason: "not_found" | "boundary" | "io" = "boundary",
|
||||
readonly reason: "not_found" | "boundary" | "io" | "limit" = "boundary",
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(message, options);
|
||||
@@ -77,12 +77,14 @@ export async function writeNewWorkspaceFileNoFollow(
|
||||
workspaceDir: string,
|
||||
requestedPath: string,
|
||||
source: Readable,
|
||||
maxBytes?: number,
|
||||
): Promise<string> {
|
||||
return (await writeNewWorkspaceFileNoFollowTracked(
|
||||
workspaceRoot,
|
||||
workspaceDir,
|
||||
requestedPath,
|
||||
source,
|
||||
maxBytes,
|
||||
)).path;
|
||||
}
|
||||
|
||||
@@ -92,6 +94,7 @@ export async function writeNewWorkspaceFileNoFollowTracked(
|
||||
workspaceDir: string,
|
||||
requestedPath: string,
|
||||
source: Readable,
|
||||
maxBytes?: number,
|
||||
): Promise<WorkspaceFileWriteResult> {
|
||||
const workspace = await canonicalWorkspace(workspaceRoot, workspaceDir);
|
||||
const components = fileComponents(workspace, requestedPath);
|
||||
@@ -114,8 +117,17 @@ export async function writeNewWorkspaceFileNoFollowTracked(
|
||||
const metadata = await file.stat();
|
||||
device = metadata.dev;
|
||||
inode = metadata.ino;
|
||||
let bytesWritten = 0;
|
||||
for await (const chunk of source) {
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array);
|
||||
bytesWritten += buffer.length;
|
||||
if (maxBytes !== undefined && bytesWritten > maxBytes) {
|
||||
throw new WorkspaceFileBoundaryError(
|
||||
`inbound file exceeds ${maxBytes} bytes: ${requestedPath}`,
|
||||
requestedPath,
|
||||
"limit",
|
||||
);
|
||||
}
|
||||
let offset = 0;
|
||||
while (offset < buffer.length) {
|
||||
const { bytesWritten } = await file.write(buffer, offset, buffer.length - offset, null);
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@
|
||||
* Org admin (ADR-0021): Feishu OAuth session + `/api/org/:orgSlug/*`.
|
||||
*
|
||||
* Env (see .env.example):
|
||||
* DATABASE_URL, FEISHU_APP_ID/SECRET/BOT_OPEN_ID, PORT,
|
||||
* DATABASE_URL, HUB_SILO_ORGANIZATION_ID, PORT,
|
||||
* HUB_PROJECT_WORKSPACE_ROOT, HUB_SESSION_SECRET, HUB_PUBLIC_BASE_URL.
|
||||
* Provider credentials are org-scoped encrypted records; production receives
|
||||
* the local master-key keyring only through systemd CREDENTIALS_DIRECTORY.
|
||||
|
||||
@@ -12,6 +12,8 @@ type EnvSource = Env | (() => Env);
|
||||
const DEFAULT_SONNET_MODEL = "anthropic/claude-sonnet-5";
|
||||
const DEFAULT_SONNET_LABEL = "Claude Sonnet 5";
|
||||
const DEFAULT_AGENT_MAX_TURNS = 25;
|
||||
const DEFAULT_AGENT_MAX_CONCURRENT_RUNS = 1;
|
||||
const DEFAULT_AGENT_MAX_RUN_SECONDS = 900;
|
||||
|
||||
export interface ProviderRuntimeSettings {
|
||||
readonly id: string;
|
||||
@@ -43,6 +45,8 @@ export interface RunPolicyInput {
|
||||
|
||||
export interface RunPolicy {
|
||||
readonly maxTurns: number;
|
||||
readonly maxConcurrentRuns: number;
|
||||
readonly maxRunSeconds: number;
|
||||
}
|
||||
|
||||
export interface RuntimeSettings {
|
||||
@@ -75,6 +79,16 @@ export class EnvRuntimeSettings implements RuntimeSettings {
|
||||
void input;
|
||||
return {
|
||||
maxTurns: positiveIntegerEnv(this.envSource(), "HUB_AGENT_MAX_TURNS", DEFAULT_AGENT_MAX_TURNS),
|
||||
maxConcurrentRuns: positiveIntegerEnv(
|
||||
this.envSource(),
|
||||
"HUB_AGENT_MAX_CONCURRENT_RUNS",
|
||||
DEFAULT_AGENT_MAX_CONCURRENT_RUNS,
|
||||
),
|
||||
maxRunSeconds: positiveIntegerEnv(
|
||||
this.envSource(),
|
||||
"HUB_AGENT_MAX_RUN_SECONDS",
|
||||
DEFAULT_AGENT_MAX_RUN_SECONDS,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user