mirror of
https://github.com/THU-MAIC/OpenMAIC.git
synced 2026-10-02 09:24:43 +08:00
Course documents, folders, materials and agent sessions are partitioned by an owner id that comes from a 30-day anonymous cookie, because there is no host auth layer. For one team behind one ACCESS_CODE that partition buys nothing — those visitors already share the site password — and it costs: a second browser shows an empty course list, and POST /api/stages/[id]/publish refuses every owner, because publishing a cookie partition is not something the product allows. PERSISTENCE_SHARED_OWNER_ID resolves every request to that fixed id instead. Unset — the default — changes nothing. - **ACCESS_CODE is required.** Without one the middleware lets every request through, so a single shared owner would expose one readable, editable and publishable course library to anyone who can reach the deployment. That is worse than the per-browser partitioning this setting removes, so the combination refuses to start, from instrumentation.ts, with a message naming both variables. The alternative the issue allowed — ignore it with a warning — was rejected because a silently ignored setting is exactly the confusion this feature exists to remove. - The value is validated rather than trusted: 1-128 characters of [A-Za-z0-9._-]. That excludes the reserved `anon:` prefix, which would still be refused by publish and would alias onto a cookie owner, and any character the material-key sanitiser rewrites, which could collide two ids onto one object key. A malformed value fails startup on the same path; an empty value is treated as unset, since KEY= in an env file cannot be told apart from an operator who meant to leave the feature off. - An explicit authenticatedOwnerId still outranks it, so adding a real auth layer later does not require clearing the variable first. - All four places that derive an owner honour it, including the Server Action in lib/workbench/workspace-actions.ts, which re-reads the cookie itself. That one is easy to miss and would make the workspace list and its row actions disagree about who owns a session. - The env docs state the trust model next to the variable, cross-reference SECURITY.md, and note that this answers who owns a course while DATABASE_URL answers where one lives — a second browser sees an empty list on Postgres too. This is the stopgap asked for in #1550 — a PR in the spirit of the closed #1551 — with threading a real authenticated owner left as the long-term fix. AI-assisted commit Co-authored-by: wyuc <wang-yc24@mails.tsinghua.edu.cn>
130 lines
6.1 KiB
TypeScript
130 lines
6.1 KiB
TypeScript
/**
|
|
* Process-scoped startup work.
|
|
*
|
|
* Next calls `register` once per server instance, before it serves a request.
|
|
* That makes it the only place in this app where a background schedule can
|
|
* live: a route module has no such guarantee — it can be instantiated more than
|
|
* once and gets no shutdown hook — so anything periodic started from one is
|
|
* really started per instantiation.
|
|
*
|
|
* `register` must return before the server is ready, so nothing here may block
|
|
* on I/O. Starting a timer does not.
|
|
*/
|
|
export async function register(): Promise<void> {
|
|
// Also invoked for the Edge runtime, which has neither `pg` nor timers we
|
|
// want; the persistence stack is Node-only.
|
|
if (process.env.NEXT_RUNTIME !== 'nodejs') return;
|
|
|
|
const { warnIfAccessCodeIsUnset } = await import('@/lib/server/access-code-warning');
|
|
warnIfAccessCodeIsUnset(process.env.ACCESS_CODE);
|
|
|
|
// The asset quota, read here rather than at the first persistence request.
|
|
// The provider that consumes it is lazy and memoised, so a malformed ceiling
|
|
// would otherwise let the process boot, pass its health check, and then fail
|
|
// every persistence request -- documents and runtime included -- until it was
|
|
// fixed and the process restarted. `register` runs before the server is
|
|
// ready, so throwing here is what makes a misconfigured deployment fail to
|
|
// start instead of failing to work. First, so the throw cannot skip the
|
|
// teardown registration for something this function has already started.
|
|
const { resolveAssetQuotaBytes } = await import('@/lib/persistence/asset-quota');
|
|
resolveAssetQuotaBytes();
|
|
|
|
// The pending-allocation window, for the same reason and at the same moment.
|
|
// Too short is worse than malformed: it silently expires allocations whose
|
|
// document write was still coming, so it must fail the process rather than
|
|
// the request that discovers it.
|
|
const { resolveAssetPendingTtlMs } = await import('@/lib/persistence/asset-pending-ttl');
|
|
resolveAssetPendingTtlMs();
|
|
|
|
// The shared owner id, for the same reason and at the same moment. A
|
|
// malformed value would otherwise boot, pass its health check, and then fail
|
|
// every owner-scoped request — and an operator who meant to share one course
|
|
// library has no way to tell from the outside that their setting was not
|
|
// accepted. `resolveSharedOwnerId` treats an empty value as unset, so this
|
|
// only rejects values that are present and unusable.
|
|
const { resolveSharedOwnerId } = await import('@/lib/server/agent-runtime/shared-owner');
|
|
resolveSharedOwnerId();
|
|
|
|
// Imported dynamically so the Edge bundle never pulls in `pg`.
|
|
const { startAssetCollectorSchedule } =
|
|
await import('@/lib/persistence/asset-collector-schedule');
|
|
const assetSchedule = startAssetCollectorSchedule();
|
|
|
|
// Warn-first boot-time validation of model routing config (MODEL_ROUTES,
|
|
// DEFAULT_MODEL, <PREFIX>_MODELS). Cheap and non-throwing: broken config
|
|
// surfaces here as [config] warnings instead of failing at request time.
|
|
// Imported dynamically so the Edge bundle never pulls in the fs/js-yaml
|
|
// backed provider config it reads.
|
|
const { validateServerConfig } = await import('@/lib/server/config-validation');
|
|
validateServerConfig();
|
|
|
|
let runner: import('@/lib/server/agent-runtime/runner').AgentRunnerHandle | undefined;
|
|
let extractionRunner:
|
|
| import('@/lib/server/material-extraction/runner').MaterialExtractionRunnerHandle
|
|
| undefined;
|
|
let stopAgentEventNotifyBus: (() => Promise<void>) | null = null;
|
|
try {
|
|
const { isAgentRuntimeConfigured } = await import('@/lib/config/feature-flags');
|
|
if (isAgentRuntimeConfigured()) {
|
|
// One dedicated LISTEN connection per application instance. The HTTP
|
|
// SSE routes and the runner share its in-process fanout registry; it is
|
|
// not a pool client and never scales with the number of streams.
|
|
const { startAgentEventNotifyBus } =
|
|
await import('@/lib/server/agent-runtime/event-notify-bus');
|
|
const eventNotifyBus = startAgentEventNotifyBus();
|
|
stopAgentEventNotifyBus = () => eventNotifyBus.stop();
|
|
// startAgentRunner only installs a timer. Store/schema initialization is
|
|
// retained behind the store's lazy promise and never blocks register().
|
|
const runtime = await import('@/lib/server/agent-runtime/runner');
|
|
runner = runtime.startAgentRunner();
|
|
const extraction = await import('@/lib/server/material-extraction/runner');
|
|
extractionRunner = extraction.startMaterialExtractionRunner();
|
|
}
|
|
} catch (error) {
|
|
console.error('[instrumentation] Agent runtime startup failed', error);
|
|
}
|
|
|
|
let shutdownPromise: Promise<void> | undefined;
|
|
const shutdown = (): Promise<void> => {
|
|
shutdownPromise ??= (async () => {
|
|
// Park sessions before any pool they use is closed. This preserves the
|
|
// last durable entry-tree checkpoint for immediate takeover.
|
|
try {
|
|
await extractionRunner?.stop();
|
|
} catch (error) {
|
|
console.error('[instrumentation] Material extraction runner drain failed', error);
|
|
}
|
|
try {
|
|
await runner?.stop();
|
|
} catch (error) {
|
|
console.error('[instrumentation] Agent runner drain failed', error);
|
|
}
|
|
try {
|
|
await stopAgentEventNotifyBus?.();
|
|
} catch (error) {
|
|
console.error('[instrumentation] Agent event notify bus drain failed', error);
|
|
}
|
|
try {
|
|
await assetSchedule?.stop();
|
|
} catch (error) {
|
|
console.error('[instrumentation] Asset collector drain failed', error);
|
|
}
|
|
const connectionString = process.env.DATABASE_URL?.trim();
|
|
if (connectionString) {
|
|
try {
|
|
const { getServerPersistenceProvider } =
|
|
await import('@/lib/persistence/server-provider');
|
|
const { pool } = await getServerPersistenceProvider(connectionString);
|
|
await pool.end();
|
|
} catch (error) {
|
|
console.error('[instrumentation] Persistence pool shutdown failed', error);
|
|
}
|
|
}
|
|
})();
|
|
return shutdownPromise;
|
|
};
|
|
|
|
process.once('SIGTERM', () => void shutdown());
|
|
process.once('SIGINT', () => void shutdown());
|
|
}
|