forked from bai/curriculum-project-hub
ae5f78f036
Exempt SPA static assets and admin HTML shell from silo HTTP rate limit so page loads no longer exhaust HUB_HTTP_REQUESTS_PER_MINUTE.
201 lines
8.4 KiB
TypeScript
201 lines
8.4 KiB
TypeScript
import Fastify from "fastify";
|
|
import { registerAdminPlugin } from "./admin/plugin.js";
|
|
import { prisma } from "./db.js";
|
|
import { createLarkClient, startFeishuListenerWithClient } from "./feishu/client.js";
|
|
import { archiveFeishuBindingForLifecycleEvent } from "./feishu/bindingLifecycle.js";
|
|
import { makeTriggerHandler } from "./feishu/trigger.js";
|
|
import { removeAbandonedMessageResourceStages } from "./feishu/resourceStaging.js";
|
|
import { triggerQueue } from "./feishu/triggerQueue.js";
|
|
import { LocalSecretEnvelope, loadLocalSecretKeyring } from "./security/secretEnvelope.js";
|
|
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 { isSiloHttpRateLimitExempt, SiloFixedWindowRateLimiter } from "./deployment/siloRateLimit.js";
|
|
|
|
function requireEnv(name: string): string {
|
|
const value = process.env[name];
|
|
if (value === undefined || value === "") {
|
|
throw new Error(`missing required env: ${name}`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function booleanEnv(name: string, fallback: boolean): boolean {
|
|
const raw = process.env[name];
|
|
if (raw === undefined || raw === "") return fallback;
|
|
return !["0", "false", "no", "off"].includes(raw.trim().toLowerCase());
|
|
}
|
|
|
|
/** Start the Hub after every import and runtime dependency has loaded. */
|
|
export async function startHub(): Promise<void> {
|
|
requireEnv("DATABASE_URL");
|
|
const projectWorkspaceRoot = requireEnv("HUB_PROJECT_WORKSPACE_ROOT");
|
|
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) => {
|
|
// Static SPA assets / admin HTML shell / healthz do not count toward the
|
|
// silo requestRate budget (a full admin load can pull dozens of chunks).
|
|
if (isSiloHttpRateLimitExempt(request.url)) 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");
|
|
|
|
const secretEnvelope = new LocalSecretEnvelope(await loadLocalSecretKeyring());
|
|
const verifiedProviderEnvelopes = await verifyStoredProviderEnvelopes(prisma, secretEnvelope);
|
|
const verifiedFeishuEnvelopes = await verifyStoredFeishuApplicationEnvelopes(prisma, secretEnvelope);
|
|
app.log.info(
|
|
{ provider: verifiedProviderEnvelopes, feishu: verifiedFeishuEnvelopes },
|
|
"startup: authenticated stored connection envelopes",
|
|
);
|
|
const runtimeSettings = createDatabaseRuntimeSettings(
|
|
prisma,
|
|
secretEnvelope,
|
|
process.env,
|
|
(credential, context) => openProviderProxyLease(credential, {
|
|
onDiagnostic: (diagnostic) => {
|
|
app.log.error({
|
|
runId: context.runId,
|
|
projectId: context.projectId,
|
|
providerId: context.providerId,
|
|
errorCode: diagnostic.code,
|
|
failureCategory: diagnostic.category,
|
|
}, "provider proxy diagnostic");
|
|
},
|
|
}),
|
|
);
|
|
|
|
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();
|
|
|
|
// Startup reset: clear stale locks + mark dead runs as FAILED.
|
|
await prisma.projectAgentLock.deleteMany({});
|
|
await prisma.agentRun.updateMany({
|
|
where: { status: "ACTIVE" },
|
|
data: { status: "FAILED", error: "process restart", finishedAt: new Date() },
|
|
});
|
|
app.log.info("startup: cleared stale locks + dead runs");
|
|
|
|
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: feishuApplication.appId,
|
|
feishuAppSecret: feishuApplication.appSecret,
|
|
projectWorkspaceRoot,
|
|
secretEnvelope,
|
|
});
|
|
|
|
const feishuListenerEnabled = booleanEnv("HUB_FEISHU_LISTENER_ENABLED", true);
|
|
if (feishuListenerEnabled) {
|
|
const feishuConfig = {
|
|
appId: feishuApplication.appId,
|
|
appSecret: feishuApplication.appSecret,
|
|
botOpenId: feishuApplication.botOpenId,
|
|
};
|
|
const larkClient = createLarkClient(feishuConfig);
|
|
const triggerQueuePurgeTimer = setInterval(() => {
|
|
const removed = triggerQueue.purgeExpired();
|
|
if (removed > 0) {
|
|
app.log.info({ removed }, "feishu trigger queue: purged expired triggers");
|
|
}
|
|
}, 60_000);
|
|
triggerQueuePurgeTimer.unref();
|
|
const trigger = makeTriggerHandler({
|
|
prisma,
|
|
settings: runtimeSettings,
|
|
logger: app.log,
|
|
projectWorkspaceRoot,
|
|
publicBaseUrl,
|
|
siloOrganizationId: siloOrganization.id,
|
|
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"));
|
|
},
|
|
async (event) => {
|
|
const result = await archiveFeishuBindingForLifecycleEvent(prisma, event);
|
|
app.log.info({ ...event, archived: result.archived, projectId: result.projectId }, "feishu binding lifecycle event handled");
|
|
},
|
|
);
|
|
} 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;
|
|
}
|