ima2-gen 3.13.1 → 3.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +10 -0
- package/CHANGELOG.md +14 -0
- package/README.md +15 -2
- package/bin/commands/capabilities.js +5 -3
- package/bin/commands/config.js +15 -4
- package/bin/commands/defaults.js +7 -5
- package/bin/commands/doctor.js +88 -39
- package/bin/commands/gen.js +21 -4
- package/bin/commands/models.js +2 -2
- package/bin/commands/ping.js +1 -1
- package/bin/commands/prompt.js +5 -3
- package/bin/commands/service.js +32 -4
- package/bin/commands/tools.js +19 -3
- package/bin/commands/upscale.js +18 -4
- package/bin/commands/video.js +37 -50
- package/bin/ima2.js +28 -9
- package/bin/lib/characterResolve.js +2 -1
- package/bin/lib/client.js +155 -20
- package/bin/lib/config-store.js +40 -7
- package/bin/lib/doctor-bundle.js +6 -2
- package/bin/lib/doctor-checks.js +13 -29
- package/bin/lib/doctor-media.js +3 -3
- package/bin/lib/doctor-providers.js +48 -35
- package/bin/lib/doctor-report.js +78 -0
- package/bin/lib/doctor-runtime.js +173 -0
- package/bin/lib/mcpJob.js +19 -7
- package/bin/lib/output.js +3 -0
- package/bin/lib/platform.js +12 -9
- package/bin/lib/recover-output.js +9 -2
- package/bin/lib/sse.js +9 -1
- package/bin/lib/videoMcp.js +16 -8
- package/config.js +41 -5
- package/docs/API.md +154 -16
- package/docs/CLI.md +65 -1
- package/docs/DOCKER.md +19 -4
- package/docs/DOCKER.zh-CN.md +11 -4
- package/docs/DOCKER.zh-TW.md +11 -4
- package/docs/FAQ.md +13 -0
- package/docs/NPX_QUICKSTART.md +6 -0
- package/docs/README.ja.md +15 -2
- package/docs/README.ko.md +15 -2
- package/docs/README.zh-CN.md +15 -2
- package/docs/README.zh-TW.md +15 -2
- package/docs/migration/runtime-test-inventory.md +79 -8
- package/lib/agyArtifact.js +82 -0
- package/lib/agyArtifactRead.js +249 -0
- package/lib/agyImageAdapter.js +2 -329
- package/lib/agyProcess.js +122 -0
- package/lib/apiRequestBudget.js +48 -0
- package/lib/assetLifecycle.js +42 -9
- package/lib/canvasVersionStore.js +46 -14
- package/lib/capabilities.js +7 -1
- package/lib/configKeys.js +2 -0
- package/lib/errors/providerMap.js +26 -0
- package/lib/eventBus.js +10 -6
- package/lib/eventsPolicy.js +9 -0
- package/lib/geminiApiImageAdapter.js +1 -214
- package/lib/generatePipeline.js +39 -165
- package/lib/generatedMediaAccess.js +74 -0
- package/lib/generationErrors.js +3 -22
- package/lib/grokImageAdapter.js +2 -353
- package/lib/grokImageCore.js +1 -57
- package/lib/grokImageDownload.js +159 -0
- package/lib/grokImageDownloadPolicy.js +124 -0
- package/lib/grokImagePlanner.js +301 -0
- package/lib/grokMultimodeAdapter.js +1 -75
- package/lib/grokVideoDownload.js +137 -34
- package/lib/inflight.js +112 -112
- package/lib/jobs/terminalStore.js +45 -0
- package/lib/lanSessionStore.js +131 -0
- package/lib/localAccessPolicy.js +180 -0
- package/lib/localLanAccess.js +281 -0
- package/lib/logger.js +22 -13
- package/lib/mcp/downloadMediaResult.js +72 -78
- package/lib/multimodePipeline.js +39 -126
- package/lib/nodeGeneration.js +44 -101
- package/lib/nodeStore.js +25 -11
- package/lib/nodeValidation.js +5 -1
- package/lib/pinnedHttpGet.js +174 -0
- package/lib/promptImport/githubFolder.js +32 -18
- package/lib/promptImport/githubSource.js +14 -10
- package/lib/promptImport/parsePromptCandidates.js +32 -2
- package/lib/providers/adapters/agyOperations.js +179 -0
- package/lib/providers/adapters/comfy.js +1 -0
- package/lib/providers/adapters/geminiOperations.js +214 -0
- package/lib/providers/adapters/googleExecution.js +97 -0
- package/lib/providers/adapters/grokExecution.js +137 -0
- package/lib/providers/adapters/grokMultimodeOperations.js +83 -0
- package/lib/providers/adapters/grokOperations.js +63 -0
- package/lib/providers/adapters/openaiExecution.js +118 -0
- package/lib/providers/adapters/openaiOperations.js +201 -0
- package/lib/providers/adapters/openaiTypes.js +1 -0
- package/lib/providers/derive.js +4 -0
- package/lib/providers/deriveCore.js +2 -7
- package/lib/providers/execution/admission.js +25 -0
- package/lib/providers/execution/index.js +34 -0
- package/lib/providers/execution/legacy.js +17 -0
- package/lib/providers/execution/legacyClassic.js +66 -0
- package/lib/providers/execution/legacyEdit.js +30 -0
- package/lib/providers/execution/legacyMultimode.js +34 -0
- package/lib/providers/execution/legacyNode.js +41 -0
- package/lib/providers/execution/types.js +1 -0
- package/lib/providers/registry.js +19 -14
- package/lib/providers/surfaceSupport.js +22 -0
- package/lib/responsesImageAdapter.js +1 -389
- package/lib/responsesTransport.js +190 -0
- package/lib/spriteJobEvents.js +20 -2
- package/lib/ssePublish.js +5 -4
- package/lib/videoExtendI2vOperation.js +76 -0
- package/package.json +5 -2
- package/routes/edit.js +31 -123
- package/routes/events.js +140 -63
- package/routes/mcpMedia.js +10 -2
- package/routes/models.js +28 -25
- package/routes/videoExtended.js +9 -68
- package/server.js +24 -46
- package/ui/dist/.vite/manifest.json +90 -63
- package/ui/dist/assets/{AgentWorkspace-PbBytTeV.js → AgentWorkspace-CL3MLv3l.js} +2 -2
- package/ui/dist/assets/App-B9cEM6dJ.js +9 -0
- package/ui/dist/assets/AssetGenWorkspace-swWPvXrx.js +2 -0
- package/ui/dist/assets/AssetsWorkspace-CRVeYU2F.js +1 -0
- package/ui/dist/assets/{CardNewsWorkspace-BS3jKOja.js → CardNewsWorkspace-BAOxWLKf.js} +2 -2
- package/ui/dist/assets/GenerationRequestLogPanel-DZtYZm-D.js +1 -0
- package/ui/dist/assets/HomeWorkspace-BeZ1s0Jm.js +1 -0
- package/ui/dist/assets/KeyingPanel-ZyBSXioh.js +1 -0
- package/ui/dist/assets/NodeCanvas-BvvcCErf.js +7 -0
- package/ui/dist/assets/{PromptBuilderPanel-D97A4pwB.js → PromptBuilderPanel-DBycmShA.js} +2 -2
- package/ui/dist/assets/PromptImportDialog-ChvMv7ek.js +2 -0
- package/ui/dist/assets/PromptImportDiscoverySection-60py7whH.js +1 -0
- package/ui/dist/assets/PromptImportFolderSection-CCRh_-_7.js +1 -0
- package/ui/dist/assets/PromptLibraryPanel-BFWQO8OT.js +2 -0
- package/ui/dist/assets/SettingsWorkspace-B5Ghsgdj.js +1 -0
- package/ui/dist/assets/SpriteRecipeWorkspace-DRWdkmwU.js +1 -0
- package/ui/dist/assets/index-7ngEOVxt.js +5 -0
- package/ui/dist/assets/index-B5jy7ePg.css +1 -0
- package/ui/dist/assets/index-gxh_02DQ.js +22 -0
- package/ui/dist/assets/{pptxgen.es-D4pedbwM.js → pptxgen.es-OUNhQLEV.js} +1 -1
- package/ui/dist/assets/promptBuilderStore-BTUUzbBT.js +1 -0
- package/ui/dist/assets/useAgentDialogFocus-DSc2qY8p.js +1 -0
- package/ui/dist/index.html +3 -2
- package/ui/dist/assets/AssetGenWorkspace-B-TGlvVb.js +0 -2
- package/ui/dist/assets/AssetsWorkspace-LnGaW151.js +0 -1
- package/ui/dist/assets/GenerationRequestLogPanel-DuDWD_ic.js +0 -1
- package/ui/dist/assets/HomeWorkspace-DVP7UG6O.js +0 -1
- package/ui/dist/assets/KeyingPanel-5bRUFQaq.js +0 -1
- package/ui/dist/assets/NodeCanvas-BBEpERTb.js +0 -7
- package/ui/dist/assets/PromptImportDialog-C89MN5JY.js +0 -2
- package/ui/dist/assets/PromptImportDiscoverySection-BgRfDOs2.js +0 -1
- package/ui/dist/assets/PromptImportFolderSection-zTMyriT1.js +0 -1
- package/ui/dist/assets/PromptLibraryPanel-BD96qZu_.js +0 -2
- package/ui/dist/assets/SettingsWorkspace-BAOk3X_8.js +0 -1
- package/ui/dist/assets/SpriteRecipeWorkspace-ClIM5QRo.js +0 -1
- package/ui/dist/assets/index-0fpfY8vu.css +0 -1
- package/ui/dist/assets/index-BFciPGTn.js +0 -5
- package/ui/dist/assets/index-BbykeXR9.js +0 -30
- package/ui/dist/assets/promptBuilderStore-Do7M8g7J.js +0 -1
- package/ui/dist/assets/useAgentDialogFocus-B6-QgEVI.js +0 -1
package/routes/events.js
CHANGED
|
@@ -1,16 +1,7 @@
|
|
|
1
|
-
import { subscribe, replaySince, hasReplayGap, replayOldestId, MAX_SSE_LISTENERS } from "../lib/eventBus.js";
|
|
1
|
+
import { subscribe, replaySince, hasReplayGap, replayOldestId, latestEventId, MAX_SSE_LISTENERS } from "../lib/eventBus.js";
|
|
2
|
+
import { SSE_STREAM_POLICY } from "../lib/eventsPolicy.js";
|
|
2
3
|
let activeConnections = 0;
|
|
3
|
-
|
|
4
|
-
if (res.writableEnded || res.destroyed)
|
|
5
|
-
return false;
|
|
6
|
-
try {
|
|
7
|
-
res.write(chunk);
|
|
8
|
-
return true;
|
|
9
|
-
}
|
|
10
|
-
catch {
|
|
11
|
-
return false;
|
|
12
|
-
}
|
|
13
|
-
}
|
|
4
|
+
const HEARTBEAT_MS = 15_000;
|
|
14
5
|
function formatSse(ev) {
|
|
15
6
|
// jobSeq and envelope are additive: the publisher already built them, so this
|
|
16
7
|
// only serializes. Consumers that ignore the new fields see the old payload.
|
|
@@ -25,6 +16,140 @@ function parseLastEventIdHeader(value) {
|
|
|
25
16
|
const raw = Array.isArray(value) ? value[0] : value;
|
|
26
17
|
return parseInt(raw?.split(",", 1)[0]?.trim() ?? "", 10);
|
|
27
18
|
}
|
|
19
|
+
class EventConnection {
|
|
20
|
+
req;
|
|
21
|
+
res;
|
|
22
|
+
cursor;
|
|
23
|
+
closed = false;
|
|
24
|
+
pumping = false;
|
|
25
|
+
blocked = false;
|
|
26
|
+
unsubscribe = () => { };
|
|
27
|
+
heartbeat;
|
|
28
|
+
deadline;
|
|
29
|
+
constructor(req, res, cursor) {
|
|
30
|
+
this.req = req;
|
|
31
|
+
this.res = res;
|
|
32
|
+
this.cursor = cursor;
|
|
33
|
+
}
|
|
34
|
+
start() {
|
|
35
|
+
activeConnections++;
|
|
36
|
+
this.req.on("close", this.close);
|
|
37
|
+
this.res.on("close", this.close);
|
|
38
|
+
this.res.on("error", this.close);
|
|
39
|
+
this.res.on("drain", this.drain);
|
|
40
|
+
try {
|
|
41
|
+
this.res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
|
|
42
|
+
this.res.setHeader("Cache-Control", "no-cache, no-transform");
|
|
43
|
+
this.res.setHeader("Connection", "keep-alive");
|
|
44
|
+
this.res.setHeader("X-Accel-Buffering", "no");
|
|
45
|
+
this.res.setHeader("x-ima2-event-cursor", String(this.cursor));
|
|
46
|
+
this.res.flushHeaders?.();
|
|
47
|
+
if (this.closed)
|
|
48
|
+
return;
|
|
49
|
+
this.unsubscribe = subscribe(this.live);
|
|
50
|
+
this.pump();
|
|
51
|
+
if (!this.closed)
|
|
52
|
+
this.heartbeat = setInterval(this.ping, HEARTBEAT_MS);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
this.close();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
write(chunk) {
|
|
59
|
+
if (this.closed || this.blocked)
|
|
60
|
+
return false;
|
|
61
|
+
if (this.res.writableEnded || this.res.destroyed) {
|
|
62
|
+
this.close();
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
const ready = this.res.write(chunk);
|
|
67
|
+
if (this.closed)
|
|
68
|
+
return false;
|
|
69
|
+
if (!ready) {
|
|
70
|
+
this.blocked = true;
|
|
71
|
+
this.deadline = setTimeout(this.close, SSE_STREAM_POLICY.drainTimeoutMs);
|
|
72
|
+
}
|
|
73
|
+
return ready;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
this.close();
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
gap() {
|
|
81
|
+
if (!hasReplayGap(this.cursor))
|
|
82
|
+
return true;
|
|
83
|
+
const oldestAvailableId = replayOldestId();
|
|
84
|
+
const data = JSON.stringify({ lastEventId: this.cursor, oldestAvailableId });
|
|
85
|
+
this.cursor = oldestAvailableId === null ? latestEventId() : oldestAvailableId - 1;
|
|
86
|
+
return this.write(`event: replay-gap\ndata: ${data}\n\n`);
|
|
87
|
+
}
|
|
88
|
+
pump() {
|
|
89
|
+
if (this.closed || this.blocked || this.pumping)
|
|
90
|
+
return;
|
|
91
|
+
this.pumping = true;
|
|
92
|
+
try {
|
|
93
|
+
do {
|
|
94
|
+
if (!this.gap())
|
|
95
|
+
return;
|
|
96
|
+
for (const ev of replaySince(this.cursor)) {
|
|
97
|
+
// write(false) accepts the chunk; drain must start AFTER this ID.
|
|
98
|
+
this.cursor = ev.id;
|
|
99
|
+
if (!this.write(formatSse(ev)))
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
} while (!this.closed && this.cursor < latestEventId());
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
this.pumping = false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
live = (ev) => {
|
|
109
|
+
if (this.closed || this.blocked || this.pumping || ev.id <= this.cursor)
|
|
110
|
+
return;
|
|
111
|
+
if (ev.id !== this.cursor + 1) {
|
|
112
|
+
this.pump();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
this.pumping = true;
|
|
116
|
+
try {
|
|
117
|
+
this.cursor = ev.id;
|
|
118
|
+
this.write(formatSse(ev));
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
this.pumping = false;
|
|
122
|
+
}
|
|
123
|
+
this.pump();
|
|
124
|
+
};
|
|
125
|
+
ping = () => {
|
|
126
|
+
if (!this.blocked && !this.pumping)
|
|
127
|
+
this.write(": ping\n\n");
|
|
128
|
+
};
|
|
129
|
+
drain = () => {
|
|
130
|
+
if (this.closed || !this.blocked)
|
|
131
|
+
return;
|
|
132
|
+
clearTimeout(this.deadline);
|
|
133
|
+
this.deadline = undefined;
|
|
134
|
+
this.blocked = false;
|
|
135
|
+
this.pump();
|
|
136
|
+
};
|
|
137
|
+
close = () => {
|
|
138
|
+
if (this.closed)
|
|
139
|
+
return;
|
|
140
|
+
this.closed = true;
|
|
141
|
+
this.unsubscribe();
|
|
142
|
+
clearInterval(this.heartbeat);
|
|
143
|
+
clearTimeout(this.deadline);
|
|
144
|
+
this.req.off("close", this.close);
|
|
145
|
+
this.res.off("close", this.close);
|
|
146
|
+
this.res.off("error", this.close);
|
|
147
|
+
this.res.off("drain", this.drain);
|
|
148
|
+
activeConnections--;
|
|
149
|
+
if (!this.res.destroyed)
|
|
150
|
+
this.res.destroy();
|
|
151
|
+
};
|
|
152
|
+
}
|
|
28
153
|
export function registerEventsRoute(app, _ctx) {
|
|
29
154
|
app.get("/api/events", (req, res) => {
|
|
30
155
|
if (activeConnections >= MAX_SSE_LISTENERS) {
|
|
@@ -32,58 +157,10 @@ export function registerEventsRoute(app, _ctx) {
|
|
|
32
157
|
error: { code: "SSE_CAPACITY", message: "Too many event stream connections" },
|
|
33
158
|
});
|
|
34
159
|
}
|
|
35
|
-
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
|
|
36
|
-
res.setHeader("Cache-Control", "no-cache, no-transform");
|
|
37
|
-
res.setHeader("Connection", "keep-alive");
|
|
38
|
-
res.setHeader("X-Accel-Buffering", "no");
|
|
39
|
-
res.flushHeaders?.();
|
|
40
|
-
activeConnections++;
|
|
41
160
|
const headerLastId = parseLastEventIdHeader(req.headers["last-event-id"]);
|
|
42
161
|
const queryLastId = parseInt(String(req.query.lastEventId ?? ""), 10);
|
|
43
|
-
const lastId =
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const gapPayload = JSON.stringify({
|
|
47
|
-
lastEventId: lastId,
|
|
48
|
-
oldestAvailableId: replayOldestId(),
|
|
49
|
-
});
|
|
50
|
-
if (!safeWrite(res, `event: replay-gap\ndata: ${gapPayload}\n\n`)) {
|
|
51
|
-
activeConnections = Math.max(0, activeConnections - 1);
|
|
52
|
-
return;
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
for (const ev of replaySince(lastId)) {
|
|
56
|
-
if (!safeWrite(res, formatSse(ev)))
|
|
57
|
-
break;
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
let cleaned = false;
|
|
61
|
-
const unsub = subscribe((ev) => {
|
|
62
|
-
if (!safeWrite(res, formatSse(ev)))
|
|
63
|
-
cleanup();
|
|
64
|
-
});
|
|
65
|
-
const heartbeat = setInterval(() => {
|
|
66
|
-
if (!safeWrite(res, ": ping\n\n"))
|
|
67
|
-
cleanup();
|
|
68
|
-
}, 15_000);
|
|
69
|
-
function cleanup() {
|
|
70
|
-
if (cleaned)
|
|
71
|
-
return;
|
|
72
|
-
cleaned = true;
|
|
73
|
-
unsub();
|
|
74
|
-
clearInterval(heartbeat);
|
|
75
|
-
activeConnections = Math.max(0, activeConnections - 1);
|
|
76
|
-
if (!res.writableEnded && !res.destroyed) {
|
|
77
|
-
try {
|
|
78
|
-
res.end();
|
|
79
|
-
}
|
|
80
|
-
catch {
|
|
81
|
-
/* socket already torn down */
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
req.on("close", cleanup);
|
|
86
|
-
res.on("close", cleanup);
|
|
87
|
-
res.on("error", cleanup);
|
|
162
|
+
const lastId = Number.isSafeInteger(headerLastId) ? headerLastId : queryLastId;
|
|
163
|
+
const cursor = Number.isSafeInteger(lastId) ? Math.max(0, lastId) : latestEventId();
|
|
164
|
+
new EventConnection(req, res, cursor).start();
|
|
88
165
|
});
|
|
89
166
|
}
|
package/routes/mcpMedia.js
CHANGED
|
@@ -160,8 +160,16 @@ async function runMediaAction(input) {
|
|
|
160
160
|
if (input.mode === "fallback" && input.decision === "local-ffmpeg-concat") {
|
|
161
161
|
setJobPhase(requestId, "media-processing");
|
|
162
162
|
publishJobEvent(requestId, "progress", { phase: "media-processing" });
|
|
163
|
-
const tempOut = join(ctx.config.storage.generatedDir, `.tmp-concat-${
|
|
164
|
-
|
|
163
|
+
const tempOut = join(ctx.config.storage.generatedDir, `.tmp-concat-${randomBytes(16).toString("hex")}.mp4`);
|
|
164
|
+
try {
|
|
165
|
+
await deps.concat(input.resolvedFiles, tempOut, { signal: input.signal });
|
|
166
|
+
}
|
|
167
|
+
catch (concatError) {
|
|
168
|
+
// Concat can leave a partial file; retain its error if cleanup also fails.
|
|
169
|
+
await rm(tempOut, { force: true }).catch(() => undefined);
|
|
170
|
+
throw concatError;
|
|
171
|
+
}
|
|
172
|
+
// Successful concat hands cleanup to commitMediaResult, before its done event.
|
|
165
173
|
await commitMediaResult({
|
|
166
174
|
ctx, deps, requestId, kind: "video",
|
|
167
175
|
tempPath: tempOut, cleanup: () => rm(tempOut, { force: true }),
|
package/routes/models.js
CHANGED
|
@@ -7,7 +7,8 @@ import { getProviderAdapter } from "../lib/providers/adapters/index.js";
|
|
|
7
7
|
import { MAX_VIDEO_DURATION, MIN_VIDEO_DURATION, VALID_VIDEO_ASPECT_RATIOS, VALID_VIDEO_RESOLUTIONS, } from "../lib/imageModels.js";
|
|
8
8
|
import { getProviderModels, } from "../lib/mcp/modelsCatalog.js";
|
|
9
9
|
import { listProviders, } from "../lib/mcp/providerRegistry.js";
|
|
10
|
-
import { deriveModels } from "../lib/providers/derive.js";
|
|
10
|
+
import { deriveModels, deriveProviderIds, getProviderSurfaceSupport } from "../lib/providers/derive.js";
|
|
11
|
+
import { PROVIDER_SURFACES } from "../lib/providers/surfaceSupport.js";
|
|
11
12
|
import { listWorkflows } from "../lib/comfyWorkflowStore.js";
|
|
12
13
|
import { probeComfyOrigins } from "../lib/comfyImageAdapter.js";
|
|
13
14
|
import { requireRuntimeContext, } from "../lib/runtimeContext.js";
|
|
@@ -21,20 +22,15 @@ function emptyModels() {
|
|
|
21
22
|
function capabilities(inputRoles = [], parameters = [], aspectRatios = []) {
|
|
22
23
|
return { source: "verified-contract", aspectRatios, parameters, inputRoles };
|
|
23
24
|
}
|
|
24
|
-
|
|
25
|
-
* Text-only lanes. The default in `entries` advertises image_references, which
|
|
26
|
-
* would be a lie for a lane whose routes answer NAI_REF_UNSUPPORTED.
|
|
27
|
-
*/
|
|
28
|
-
function textOnlyCapabilities() {
|
|
29
|
-
return capabilities(["text"]);
|
|
30
|
-
}
|
|
31
|
-
function entries(ids, caps) {
|
|
25
|
+
function entries(provider, ids, caps) {
|
|
32
26
|
return [...ids].map((id) => ({
|
|
33
27
|
id,
|
|
34
28
|
label: id,
|
|
35
29
|
capabilities: caps
|
|
36
30
|
? { ...caps, aspectRatios: [...caps.aspectRatios], parameters: [...caps.parameters], inputRoles: [...caps.inputRoles] }
|
|
37
|
-
:
|
|
31
|
+
// CoreProviderId comes from the same registry as the getter: never null.
|
|
32
|
+
: capabilities(getProviderSurfaceSupport(provider, "generate").references
|
|
33
|
+
? ["text", "image_references"] : ["text"]),
|
|
38
34
|
}));
|
|
39
35
|
}
|
|
40
36
|
function videoCapabilities() {
|
|
@@ -91,21 +87,21 @@ function grokLaneState(ctx) {
|
|
|
91
87
|
return { status: "ready", reason: UNPROBED_GROK_REASON };
|
|
92
88
|
}
|
|
93
89
|
}
|
|
94
|
-
function grokLane(ctx) {
|
|
90
|
+
function grokLane(ctx, provider = "grok") {
|
|
95
91
|
const state = grokLaneState(ctx);
|
|
96
92
|
return lane(state, {
|
|
97
93
|
image: ctx.config.grokProvider.defaultImageModel,
|
|
98
94
|
video: ctx.config.grokProvider.defaultVideoModel,
|
|
99
95
|
}, {
|
|
100
|
-
image: entries(deriveModels(
|
|
101
|
-
video: entries([...deriveModels("grok", "video")].filter((model) => model !== "grok-imagine-video-1.5-preview"), videoCapabilities()),
|
|
96
|
+
image: entries(provider, deriveModels(provider, "image")),
|
|
97
|
+
video: entries(provider, [...deriveModels("grok", "video")].filter((model) => model !== "grok-imagine-video-1.5-preview"), videoCapabilities()),
|
|
102
98
|
});
|
|
103
99
|
}
|
|
104
100
|
function grokApiLane(ctx) {
|
|
105
101
|
const state = ctx.xaiApiKey
|
|
106
102
|
? { status: "ready" }
|
|
107
103
|
: { status: "key-missing", reason: "xAI API key missing" };
|
|
108
|
-
const grok = grokLane(ctx);
|
|
104
|
+
const grok = grokLane(ctx, "grok-api");
|
|
109
105
|
return lane(state, { ...grok.defaults }, grok.models);
|
|
110
106
|
}
|
|
111
107
|
function agyLane(installed) {
|
|
@@ -113,7 +109,7 @@ function agyLane(installed) {
|
|
|
113
109
|
? { status: "ready", reason: "binary installed; login cannot be probed" }
|
|
114
110
|
: { status: "disconnected", reason: "binary not installed" };
|
|
115
111
|
return lane(state, { image: "nano-banana-2" }, {
|
|
116
|
-
image: entries(deriveModels("agy", "image")), video: [],
|
|
112
|
+
image: entries("agy", deriveModels("agy", "image")), video: [],
|
|
117
113
|
});
|
|
118
114
|
}
|
|
119
115
|
function geminiLane(ctx) {
|
|
@@ -122,7 +118,7 @@ function geminiLane(ctx) {
|
|
|
122
118
|
? { status: "ready" }
|
|
123
119
|
: { status: "key-missing", reason: "Gemini API or Vertex credentials missing" };
|
|
124
120
|
return lane(state, { image: "nano-banana-2" }, {
|
|
125
|
-
image: entries(deriveModels("gemini-api", "image")), video: [],
|
|
121
|
+
image: entries("gemini-api", deriveModels("gemini-api", "image")), video: [],
|
|
126
122
|
});
|
|
127
123
|
}
|
|
128
124
|
function atlasCloudLane(ctx) {
|
|
@@ -135,7 +131,7 @@ function atlasCloudLane(ctx) {
|
|
|
135
131
|
? { status: "ready" }
|
|
136
132
|
: { status: "key-missing", reason: "Atlas Cloud API key missing" };
|
|
137
133
|
return lane(fallback, { image: ATLASCLOUD_TEXT_TO_IMAGE_MODEL }, {
|
|
138
|
-
image: entries(deriveModels("atlascloud", "image")), video: [],
|
|
134
|
+
image: entries("atlascloud", deriveModels("atlascloud", "image")), video: [],
|
|
139
135
|
});
|
|
140
136
|
}
|
|
141
137
|
const auth = adapter.validateAuth();
|
|
@@ -143,7 +139,7 @@ function atlasCloudLane(ctx) {
|
|
|
143
139
|
? { status: "ready" }
|
|
144
140
|
: { status: "key-missing", reason: auth.reason ?? "Atlas Cloud API key missing" };
|
|
145
141
|
return lane(state, { image: ATLASCLOUD_TEXT_TO_IMAGE_MODEL }, {
|
|
146
|
-
image: entries(adapter.listModels().map((model) => model.id)), video: [],
|
|
142
|
+
image: entries("atlascloud", adapter.listModels().map((model) => model.id)), video: [],
|
|
147
143
|
});
|
|
148
144
|
}
|
|
149
145
|
function minimaxLane(ctx) {
|
|
@@ -156,7 +152,7 @@ function minimaxLane(ctx) {
|
|
|
156
152
|
? { status: "ready" }
|
|
157
153
|
: { status: "key-missing", reason: "MiniMax API key missing" };
|
|
158
154
|
return lane(fallback, { image: MINIMAX_TEXT_TO_IMAGE_MODEL }, {
|
|
159
|
-
image: entries(deriveModels("minimax", "image")), video: [],
|
|
155
|
+
image: entries("minimax", deriveModels("minimax", "image")), video: [],
|
|
160
156
|
});
|
|
161
157
|
}
|
|
162
158
|
const auth = adapter.validateAuth();
|
|
@@ -164,7 +160,7 @@ function minimaxLane(ctx) {
|
|
|
164
160
|
? { status: "ready" }
|
|
165
161
|
: { status: "key-missing", reason: auth.reason ?? "MiniMax API key missing" };
|
|
166
162
|
return lane(state, { image: MINIMAX_TEXT_TO_IMAGE_MODEL }, {
|
|
167
|
-
image: entries(adapter.listModels().map((model) => model.id)), video: [],
|
|
163
|
+
image: entries("minimax", adapter.listModels().map((model) => model.id)), video: [],
|
|
168
164
|
});
|
|
169
165
|
}
|
|
170
166
|
function naiLane(ctx) {
|
|
@@ -174,7 +170,7 @@ function naiLane(ctx) {
|
|
|
174
170
|
? { status: "ready" }
|
|
175
171
|
: { status: "key-missing", reason: "NovelAI API token missing" };
|
|
176
172
|
return lane(fallback, { image: NAI_DEFAULT_IMAGE_MODEL }, {
|
|
177
|
-
image: entries(deriveModels("nai", "image")
|
|
173
|
+
image: entries("nai", deriveModels("nai", "image")), video: [],
|
|
178
174
|
});
|
|
179
175
|
}
|
|
180
176
|
const auth = adapter.validateAuth();
|
|
@@ -182,7 +178,7 @@ function naiLane(ctx) {
|
|
|
182
178
|
? { status: "ready" }
|
|
183
179
|
: { status: "key-missing", reason: auth.reason ?? "NovelAI API token missing" };
|
|
184
180
|
return lane(state, { image: NAI_DEFAULT_IMAGE_MODEL }, {
|
|
185
|
-
image: entries(adapter.listModels().map((model) => model.id)
|
|
181
|
+
image: entries("nai", adapter.listModels().map((model) => model.id)), video: [],
|
|
186
182
|
});
|
|
187
183
|
}
|
|
188
184
|
/**
|
|
@@ -232,10 +228,10 @@ async function comfyLane(ctx, deps = {}) {
|
|
|
232
228
|
});
|
|
233
229
|
}
|
|
234
230
|
async function buildCoreLanes(ctx, agyInstalled, deps = {}) {
|
|
235
|
-
const gptModels = entries(ctx.config.imageModels.valid);
|
|
236
|
-
|
|
231
|
+
const gptModels = entries("oauth", ctx.config.imageModels.valid);
|
|
232
|
+
const lanes = {
|
|
237
233
|
oauth: oauthLane(ctx, gptModels),
|
|
238
|
-
api: apiLane(ctx, entries(ctx.config.imageModels.valid)),
|
|
234
|
+
api: apiLane(ctx, entries("api", ctx.config.imageModels.valid)),
|
|
239
235
|
grok: grokLane(ctx),
|
|
240
236
|
"grok-api": grokApiLane(ctx),
|
|
241
237
|
agy: agyLane(agyInstalled),
|
|
@@ -245,6 +241,13 @@ async function buildCoreLanes(ctx, agyInstalled, deps = {}) {
|
|
|
245
241
|
nai: naiLane(ctx),
|
|
246
242
|
comfy: await comfyLane(ctx, deps),
|
|
247
243
|
};
|
|
244
|
+
for (const id of deriveProviderIds()) {
|
|
245
|
+
// Both ids and surfaces exhaust the canonical registry; MCP is never decorated.
|
|
246
|
+
lanes[id].surfaces = Object.fromEntries(PROVIDER_SURFACES.map((surface) => [
|
|
247
|
+
surface, getProviderSurfaceSupport(id, surface),
|
|
248
|
+
]));
|
|
249
|
+
}
|
|
250
|
+
return lanes;
|
|
248
251
|
}
|
|
249
252
|
function detectAgyInstalled() {
|
|
250
253
|
return new Promise((resolve) => {
|
package/routes/videoExtended.js
CHANGED
|
@@ -8,19 +8,19 @@ import { downloadVideo, generateVideoViaGrok, pollVideoUntilDone } from "../lib/
|
|
|
8
8
|
import { invalidateHistoryIndex } from "../lib/historyIndex.js";
|
|
9
9
|
import { ACTIVE_VIDEO_PROMPT_GUIDANCE, appendVideoContinuityEntry, lineageFromVideoMetadata, readVideoSidecar } from "../lib/videoContinuity.js";
|
|
10
10
|
import { assertLocalMp4, extractGeneratedVideoFrameB64, extractVideoFrame, safeGeneratedFilePath } from "../lib/videoFrameExtract.js";
|
|
11
|
-
import { finishJob, INFLIGHT_RETRY_AFTER_SECONDS, isJobCanceled, isStartJobFailure, registerJobAbortController,
|
|
11
|
+
import { finishJob, INFLIGHT_RETRY_AFTER_SECONDS, isJobCanceled, isStartJobFailure, registerJobAbortController, startJob, updateJobAdmission } from "../lib/inflight.js";
|
|
12
12
|
import { makeGenerationCanceledError } from "../lib/generationCancel.js";
|
|
13
|
-
import { publish } from "../lib/eventBus.js";
|
|
14
13
|
import { publishJobEvent } from "../lib/ssePublish.js";
|
|
15
14
|
import { normalizeBodyRequestId } from "../lib/generationInputValidation.js";
|
|
16
15
|
import { normalizeGrokVideoModel, normalizeVideoAspectRatio, normalizeVideoDuration, normalizeVideoResolution, validateVideoResolutionForRequest } from "../lib/imageModels.js";
|
|
17
16
|
import { persistVideoArtifact } from "../lib/videoArtifactPersistence.js";
|
|
18
|
-
import {
|
|
17
|
+
import { normalizeVideoLineage } from "../lib/videoLineage.js";
|
|
19
18
|
import { getMotionFragment, MOTION_PRESETS } from "../lib/videoMotionPresets.js";
|
|
20
19
|
import { errInfo } from "../lib/errInfo.js";
|
|
21
|
-
import { codedVideoError as codedError, emitPhase, envDeadline,
|
|
20
|
+
import { codedVideoError as codedError, emitPhase, envDeadline, requestSignal, requirePrompt, retryableData } from "../lib/videoExtendedHelpers.js";
|
|
22
21
|
import { DEFAULT_GROK_PLANNER_MODEL } from "../config.js";
|
|
23
22
|
import { errorEnvelopeFields } from "../lib/errors/envelope.js";
|
|
23
|
+
import { runLastFrameI2v } from "../lib/videoExtendI2vOperation.js";
|
|
24
24
|
function validationError(value) {
|
|
25
25
|
if (!value || typeof value !== "object" || !("error" in value))
|
|
26
26
|
return null;
|
|
@@ -312,70 +312,11 @@ export function registerVideoExtendedRoutes(app, ctxRaw, dependencies = {}) {
|
|
|
312
312
|
updateJobAdmission(requestId, { prompt, meta: { workflow: "last-frame-i2v", sourceVideoId, provider, model } });
|
|
313
313
|
emitPhase(requestId, "queued");
|
|
314
314
|
res.status(202).json({ ok: true, requestId, sourceVideoId, workflow: "last-frame-i2v" });
|
|
315
|
-
void (
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
let sourceImage;
|
|
321
|
-
try {
|
|
322
|
-
sourceImage = await extractFrame(ctx.config.storage.generatedDir, sourceVideoId, "last", { signal: cancelController.signal });
|
|
323
|
-
}
|
|
324
|
-
catch (error) {
|
|
325
|
-
throw extractError(error, cancelController.signal);
|
|
326
|
-
}
|
|
327
|
-
if (cancelController.signal.aborted)
|
|
328
|
-
throw makeGenerationCanceledError();
|
|
329
|
-
const parentContinuity = lineageFromVideoMetadata(sourceVideoId, parent);
|
|
330
|
-
const onEvent = (event) => {
|
|
331
|
-
setJobPhase(requestId, event.phase === "submitted" ? "streaming" : event.phase);
|
|
332
|
-
publish(requestId, event.phase, {
|
|
333
|
-
requestId,
|
|
334
|
-
...(event.phase === "submitted" ? { xaiVideoRequestId: event.xaiVideoRequestId, requestedModel: event.requestedModel, effectiveModel: event.effectiveModel, modelFallback: event.modelFallback ?? null } : {}),
|
|
335
|
-
...(event.phase === "progress" ? { progress: typeof event.progress === "number" ? event.progress / 100 : null, stalled: Boolean(event.stalled) } : {}),
|
|
336
|
-
});
|
|
337
|
-
};
|
|
338
|
-
const compiledPrompt = motion.fragment ? `${prompt}\n\nCamera motion: ${motion.fragment}.` : prompt;
|
|
339
|
-
const result = await generateVideo(compiledPrompt, ctx, {
|
|
340
|
-
model, mode: "image-to-video", duration, resolution, aspectRatio,
|
|
341
|
-
sourceImage, sourceMime: "image/png", signal: cancelController.signal,
|
|
342
|
-
requestId, continuityLineage: parentContinuity,
|
|
343
|
-
directApiKey: provider === "grok-api" ? ctx.xaiApiKey ?? undefined : undefined,
|
|
344
|
-
onEvent,
|
|
345
|
-
});
|
|
346
|
-
if (cancelController.signal.aborted)
|
|
347
|
-
throw makeGenerationCanceledError();
|
|
348
|
-
stage = "persisting";
|
|
349
|
-
emitPhase(requestId, stage);
|
|
350
|
-
const filename = createFilename(ctx);
|
|
351
|
-
const createdAt = Date.now();
|
|
352
|
-
const videoLineage = deriveChildVideoLineage(filename, sourceVideoId, parent);
|
|
353
|
-
const videoContinuity = appendVideoContinuityEntry(parentContinuity, { filename, userPrompt: prompt, revisedPrompt: result.revisedPrompt, createdAt });
|
|
354
|
-
const elapsed = +((createdAt - startedAt) / 1000).toFixed(1);
|
|
355
|
-
const video = { operation: "extend", mode: "image-to-video", sourceVideoId, sourceFrame: "last", duration: result.duration, resolution: result.resolution, aspectRatio: result.aspectRatio, xaiVideoRequestId: result.xaiVideoRequestId };
|
|
356
|
-
const metadata = { kind: "video", mediaType: "video", providerUrl: result.url, requestId, prompt, userPrompt: prompt, revisedPrompt: result.revisedPrompt, motionPresetIds: motion.ids, provider, model: result.effectiveModel, createdAt, elapsed, usage: result.usage, webSearchCalls: result.webSearchCalls, video, videoLineage, videoContinuity };
|
|
357
|
-
try {
|
|
358
|
-
await persistArtifact(ctx.config.storage.generatedDir, filename, result.videoBuffer, metadata);
|
|
359
|
-
}
|
|
360
|
-
catch (error) {
|
|
361
|
-
throw codedError(errInfo(error).message, 500, "VIDEO_PERSIST_FAILED");
|
|
362
|
-
}
|
|
363
|
-
invalidateHistoryIndex();
|
|
364
|
-
const done = { requestId, filename, url: `/generated/${encodeURIComponent(filename)}`, providerUrl: result.url, mediaType: "video", provider, model: result.effectiveModel, prompt, userPrompt: prompt, revisedPrompt: result.revisedPrompt, createdAt, elapsed, usage: result.usage, webSearchCalls: result.webSearchCalls, video, videoLineage, videoContinuity };
|
|
365
|
-
// finishJob BEFORE done (audit blocker B4): a done event must never be
|
|
366
|
-
// followed by an error from a failing inflight-completion write.
|
|
367
|
-
finishJob(requestId, { status: "completed", meta: { filename, xaiVideoRequestId: result.xaiVideoRequestId } });
|
|
368
|
-
publishJobEvent(requestId, "done", done);
|
|
369
|
-
}
|
|
370
|
-
catch (error) {
|
|
371
|
-
if (!isJobCanceled(requestId)) {
|
|
372
|
-
const info = errInfo(error);
|
|
373
|
-
// #151 stage 2: terminal failure carries the canonical envelope.
|
|
374
|
-
publishJobEvent(requestId, "error", { requestId, error: info.message, code: info.code ?? "VIDEO_EXTEND_FAILED", status: info.status ?? 500, ...retryableData(error), ...errorEnvelopeFields(error) });
|
|
375
|
-
finishJob(requestId, { status: "error", httpStatus: info.status ?? 500, errorCode: info.code ?? "VIDEO_EXTEND_FAILED", meta: { stage } });
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
})();
|
|
315
|
+
void runLastFrameI2v({
|
|
316
|
+
ctx, requestId, sourceVideoId, prompt, model, provider, duration, resolution,
|
|
317
|
+
aspectRatio, parent, motion, cancelController, extractFrame, generateVideo,
|
|
318
|
+
persistArtifact, createFilename,
|
|
319
|
+
});
|
|
379
320
|
});
|
|
380
321
|
// --- Provider-native legacy extension ---
|
|
381
322
|
app.post("/api/video/extend/native", async (req, res) => {
|
package/server.js
CHANGED
|
@@ -15,7 +15,10 @@ import { configureLogger, logError } from "./lib/logger.js";
|
|
|
15
15
|
import { createRequestLogger } from "./lib/requestLogger.js";
|
|
16
16
|
import { configureApiCachePolicy } from "./lib/apiCachePolicy.js";
|
|
17
17
|
import { configureRoutes } from "./routes/index.js";
|
|
18
|
-
import { config } from "./config.js";
|
|
18
|
+
import { API_REQUEST_POLICY, config } from "./config.js";
|
|
19
|
+
import { createApiRequestBudget } from "./lib/apiRequestBudget.js";
|
|
20
|
+
import { createLocalLanAccess, createLanApiGuard as tokenOnlyLanApiGuard } from "./lib/localLanAccess.js";
|
|
21
|
+
import { createGeneratedMediaAccess } from "./lib/generatedMediaAccess.js";
|
|
19
22
|
import { getServerPort, listenWithPortFallback } from "./lib/runtimePorts.js";
|
|
20
23
|
import { shutdownServerAndMcp, startMcpRestoreAfterListen } from "./lib/mcp/shutdown.js";
|
|
21
24
|
import { closeDb } from "./lib/db.js";
|
|
@@ -23,7 +26,6 @@ import { stopAgentQueueWorker } from "./lib/agentQueueWorker.js";
|
|
|
23
26
|
import { reapCardNewsJobs } from "./lib/cardNewsJobStore.js";
|
|
24
27
|
import { reapTerminalJobs } from "./lib/inflight.js";
|
|
25
28
|
import { errInfo } from "./lib/errInfo.js";
|
|
26
|
-
import { timingSafeEqual } from "node:crypto";
|
|
27
29
|
import { cleanupExpiredMcpTempReferences, MCP_TEMP_REFERENCE_JSON_BODY_LIMIT_BYTES, MCP_TEMP_REFERENCE_SWEEP_INTERVAL_MS, } from "./lib/mcpTempReferenceStore.js";
|
|
28
30
|
const rootDir = dirname(fileURLToPath(import.meta.url));
|
|
29
31
|
async function loadApiKey() {
|
|
@@ -210,6 +212,8 @@ function setUiStaticHeaders(res, filePath) {
|
|
|
210
212
|
const normalized = filePath.replace(/\\/g, "/");
|
|
211
213
|
if (normalized.endsWith("/index.html")) {
|
|
212
214
|
res.setHeader("Cache-Control", "no-store, max-age=0");
|
|
215
|
+
res.setHeader("Referrer-Policy", "no-referrer");
|
|
216
|
+
res.setHeader("Content-Security-Policy", "frame-ancestors 'none'");
|
|
213
217
|
return;
|
|
214
218
|
}
|
|
215
219
|
if (normalized.includes("/assets/")) {
|
|
@@ -221,44 +225,29 @@ export function isLoopbackHost(host) {
|
|
|
221
225
|
return normalized === "127.0.0.1" || normalized === "::1" || normalized === "localhost";
|
|
222
226
|
}
|
|
223
227
|
export function assertLanAccessConfiguration(host, token) {
|
|
224
|
-
if (isLoopbackHost(host)
|
|
228
|
+
if (isLoopbackHost(host))
|
|
225
229
|
return;
|
|
226
|
-
|
|
230
|
+
if (token && Buffer.byteLength(token) <= config.security.lanTokenMaxBytes)
|
|
231
|
+
return;
|
|
232
|
+
const message = "[server.security] Set a nonempty IMA2_LAN_TOKEN of at most 4096 UTF-8 bytes to enable LAN access.";
|
|
227
233
|
console.error(message);
|
|
228
234
|
throw new Error(message);
|
|
229
235
|
}
|
|
230
|
-
function tokenMatches(actual, expected) {
|
|
231
|
-
if (typeof actual !== "string")
|
|
232
|
-
return false;
|
|
233
|
-
const actualBuffer = Buffer.from(actual);
|
|
234
|
-
const expectedBuffer = Buffer.from(expected);
|
|
235
|
-
return actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer);
|
|
236
|
-
}
|
|
237
236
|
export function createLanApiGuard(host, token) {
|
|
238
|
-
|
|
239
|
-
return function lanApiGuard(req, res, next) {
|
|
240
|
-
if (!requiredToken || !req.path.startsWith("/api"))
|
|
241
|
-
return next();
|
|
242
|
-
// OAuth redirect endpoints are conventionally unauthenticated: the provider's
|
|
243
|
-
// browser redirect cannot carry x-ima2-token. Security boundary for this single
|
|
244
|
-
// path is the single-use unguessable OAuth state + PKCE (030 WP3 audit round 2);
|
|
245
|
-
// an invalid state is rejected with 400 before any token exchange.
|
|
246
|
-
if (req.path === "/api/mcp/oauth/callback")
|
|
247
|
-
return next();
|
|
248
|
-
const supplied = req.get("x-ima2-token") ?? req.query.token;
|
|
249
|
-
if (tokenMatches(supplied, requiredToken))
|
|
250
|
-
return next();
|
|
251
|
-
return res.status(401).json({
|
|
252
|
-
error: { code: "LAN_TOKEN_REQUIRED", message: "A valid IMA2 LAN token is required" },
|
|
253
|
-
});
|
|
254
|
-
};
|
|
237
|
+
return tokenOnlyLanApiGuard(host, token);
|
|
255
238
|
}
|
|
256
239
|
export function buildApp(ctx) {
|
|
257
240
|
const app = express();
|
|
241
|
+
const access = createLocalLanAccess(ctx);
|
|
242
|
+
const budget = createApiRequestBudget(API_REQUEST_POLICY);
|
|
243
|
+
const locals = Object.assign(app.locals, { disposeLocalLanAccess: access.dispose });
|
|
258
244
|
configureApiCachePolicy(app);
|
|
259
245
|
configureLogger({ level: ctx.config.log.level });
|
|
260
246
|
app.use(createRequestLogger());
|
|
261
|
-
app.use(
|
|
247
|
+
app.use(access.mediaHeaders);
|
|
248
|
+
access.registerSessionRoutes(app, budget);
|
|
249
|
+
app.use(access.guard);
|
|
250
|
+
app.use(budget);
|
|
262
251
|
app.use("/api/mcp/temp-references", express.json({ limit: MCP_TEMP_REFERENCE_JSON_BODY_LIMIT_BYTES }));
|
|
263
252
|
app.use(express.json({ limit: ctx.config.server.bodyLimit }));
|
|
264
253
|
app.use(express.static(join(ctx.rootDir, "ui", "dist"), {
|
|
@@ -267,22 +256,7 @@ export function buildApp(ctx) {
|
|
|
267
256
|
app.use("/assets", (_req, res) => {
|
|
268
257
|
res.status(404).type("text/plain").send("Asset not found");
|
|
269
258
|
});
|
|
270
|
-
app.use("/generated", (
|
|
271
|
-
if (req.path.endsWith(".json"))
|
|
272
|
-
return res.status(404).type("text/plain").send("Generated metadata is not public");
|
|
273
|
-
if (req.path.toLowerCase().endsWith(".svg")) {
|
|
274
|
-
// SVG is an active document under top-level navigation, and /generated sits
|
|
275
|
-
// outside the LAN token guard (which only covers /api). Traced output is
|
|
276
|
-
// machine-generated, but the guarantee should not rest on writer discipline:
|
|
277
|
-
// this neuters script execution for any .svg served from here, now or later.
|
|
278
|
-
res.setHeader("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'");
|
|
279
|
-
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
280
|
-
}
|
|
281
|
-
return next();
|
|
282
|
-
}, express.static(ctx.config.storage.generatedDir, {
|
|
283
|
-
maxAge: ctx.config.storage.staticMaxAge,
|
|
284
|
-
immutable: true,
|
|
285
|
-
}));
|
|
259
|
+
app.use("/generated", ...createGeneratedMediaAccess(ctx, access));
|
|
286
260
|
configureRoutes(app, ctx);
|
|
287
261
|
app.use((_req, res) => {
|
|
288
262
|
res.status(404).json({ error: { code: "NOT_FOUND", message: "Route not found" } });
|
|
@@ -302,7 +276,7 @@ export function buildApp(ctx) {
|
|
|
302
276
|
},
|
|
303
277
|
});
|
|
304
278
|
});
|
|
305
|
-
return app;
|
|
279
|
+
return Object.assign(app, { locals });
|
|
306
280
|
}
|
|
307
281
|
function runtimeHostUrl(host) {
|
|
308
282
|
if (!host || host === "0.0.0.0" || host === "::")
|
|
@@ -472,6 +446,8 @@ export async function createRuntimeContext(overrides = {}) {
|
|
|
472
446
|
export async function startServer(overrides = {}) {
|
|
473
447
|
const ctx = await createRuntimeContext(overrides);
|
|
474
448
|
assertLanAccessConfiguration(ctx.config.server.host, ctx.config.server.lanToken);
|
|
449
|
+
// Validate the lazy origin configuration before any storage migration.
|
|
450
|
+
void ctx.config.server.publicOrigins;
|
|
475
451
|
await migrateGeneratedStorage(ctx);
|
|
476
452
|
try {
|
|
477
453
|
await cleanupExpiredMcpTempReferences(ctx.config.storage.generatedDir);
|
|
@@ -547,6 +523,7 @@ export async function startServer(overrides = {}) {
|
|
|
547
523
|
clearInterval(reapTimer);
|
|
548
524
|
if (tempReferenceReapTimer)
|
|
549
525
|
clearInterval(tempReferenceReapTimer);
|
|
526
|
+
app.locals.disposeLocalLanAccess();
|
|
550
527
|
await shutdownServerAndMcp({
|
|
551
528
|
closeServer: () => new Promise((resolve) => {
|
|
552
529
|
if (server)
|
|
@@ -567,6 +544,7 @@ export async function startServer(overrides = {}) {
|
|
|
567
544
|
},
|
|
568
545
|
});
|
|
569
546
|
ctx.serverActualPort = getServerPort(server) || ctx.config.server.port;
|
|
547
|
+
server.once("close", app.locals.disposeLocalLanAccess);
|
|
570
548
|
ctx.serverUrl = `http://${runtimeHostUrl(ctx.config.server.host)}:${ctx.serverActualPort}`;
|
|
571
549
|
void startMcpRestoreAfterListen(ctx).catch((error) => {
|
|
572
550
|
console.warn(`[mcp.restore] code=${String(error?.message ?? error).split(":")[0]}`);
|