narraeon 0.5.6 → 0.5.7
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/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/dist/node/protocol/appPreferences.js +4 -2
- package/dist/node/protocol/playFailure.js +13 -0
- package/dist/node/protocol/protagonistTemplates.js +13 -0
- package/dist/node/protocol/v1.js +25 -19
- package/dist/node/protocol/worldArchives.js +7 -0
- package/dist/node/runtime/V1Runtime.js +46 -6
- package/dist/node/runtime/archive/WorldArchiveZip.js +202 -0
- package/dist/node/runtime/archive/WorldArchives.js +389 -0
- package/dist/node/runtime/config/AppPreferencesStore.js +23 -10
- package/dist/node/runtime/config/ProtagonistTemplateStore.js +134 -0
- package/dist/node/runtime/content/ContentWorkspace.js +5 -0
- package/dist/node/runtime/history/StoryExport.js +33 -0
- package/dist/node/runtime/model/FileNativeModelAdapters.js +34 -2
- package/dist/node/runtime/model/ModelHost.js +12 -1
- package/dist/node/runtime/model/ModelHostStream.js +11 -3
- package/dist/node/runtime/play/FileNativePlayTimelineStore.js +80 -4
- package/dist/node/runtime/play/LegacyModelBindingFailure.js +10 -0
- package/dist/node/runtime/play/PlayCallChain.js +144 -52
- package/dist/node/runtime/play/PlayCallChainError.js +3 -1
- package/dist/node/runtime/play/PlayContinuity.js +3 -20
- package/dist/node/runtime/play/PlayFailure.js +20 -0
- package/dist/node/runtime/play/PlayTraceRetention.js +3 -11
- package/dist/node/runtime/prompt/FileNativePromptCompiler.js +4 -22
- package/dist/node/runtime/prompt/FileNativeToolRegistry.js +2 -2
- package/dist/node/runtime/world/FileNativeWorldStore.js +172 -39
- package/dist/node/runtime/world/WorldActivity.js +143 -0
- package/dist/node/server/createServer.js +48 -0
- package/dist/node/shared/default-preset-host.js +10 -6
- package/dist/node/shared/ordered-play-prompts.js +2 -2
- package/dist/node/shared/sample-packages/index.js +10 -0
- package/dist/node/shared/sample-packages/rainy-inn-en.js +194 -0
- package/dist/node/shared/sample-packages/rainy-inn-zh.js +194 -0
- package/dist/web/assets/index-DxNtxzLR.js +1255 -0
- package/dist/web/assets/{index-CVN48xrw.css → index-DzOnu9uv.css} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +6 -2
- package/dist/web/assets/index-BH8NyuE4.js +0 -1233
package/README.md
CHANGED
|
@@ -178,7 +178,7 @@ TMPDIR=/tmp npm run test:package
|
|
|
178
178
|
|
|
179
179
|
Project contracts and architecture:
|
|
180
180
|
|
|
181
|
-
- [
|
|
181
|
+
- [GLOSSARY.md](GLOSSARY.md) — domain vocabulary
|
|
182
182
|
- [docs/product-foundation.md](docs/product-foundation.md) — current V1 product contract
|
|
183
183
|
- [docs/adr/](docs/adr/) — architectural decisions
|
|
184
184
|
|
package/README.zh-CN.md
CHANGED
|
@@ -168,7 +168,7 @@ TMPDIR=/tmp npm run test:package
|
|
|
168
168
|
|
|
169
169
|
项目契约与架构文档:
|
|
170
170
|
|
|
171
|
-
- [
|
|
171
|
+
- [GLOSSARY.md](GLOSSARY.md) —— 领域词汇
|
|
172
172
|
- [docs/product-foundation.md](docs/product-foundation.md) —— 当前 V1 产品契约
|
|
173
173
|
- [docs/adr/](docs/adr/) —— 架构决策
|
|
174
174
|
|
|
@@ -7,6 +7,7 @@ export const defaultAppReadingPreferences = {
|
|
|
7
7
|
lineHeight: 1.7,
|
|
8
8
|
letterSpacing: 0.01,
|
|
9
9
|
measure: 48,
|
|
10
|
+
showTechnicalInfo: false,
|
|
10
11
|
};
|
|
11
12
|
export function isAppLocale(value) {
|
|
12
13
|
return appLocales.includes(value);
|
|
@@ -15,12 +16,13 @@ export function isAppReadingPreferences(value) {
|
|
|
15
16
|
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
16
17
|
return false;
|
|
17
18
|
const candidate = value;
|
|
18
|
-
return (Object.keys(candidate).length ===
|
|
19
|
+
return (Object.keys(candidate).length === 6 &&
|
|
19
20
|
readingDensities.includes(candidate.density) &&
|
|
20
21
|
isNumberInRange(candidate.fontSize, 15, 24) &&
|
|
21
22
|
isNumberInRange(candidate.lineHeight, 1.4, 2.4) &&
|
|
22
23
|
isNumberInRange(candidate.letterSpacing, 0, 0.12) &&
|
|
23
|
-
isNumberInRange(candidate.measure, 32, 72)
|
|
24
|
+
isNumberInRange(candidate.measure, 32, 72) &&
|
|
25
|
+
typeof candidate.showTechnicalInfo === "boolean");
|
|
24
26
|
}
|
|
25
27
|
function isNumberInRange(value, minimum, maximum) {
|
|
26
28
|
return (typeof value === "number" &&
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export const playFailureKinds = [
|
|
2
|
+
"outcome_unknown",
|
|
3
|
+
"context_limit",
|
|
4
|
+
"output_limit",
|
|
5
|
+
"context_unavailable",
|
|
6
|
+
"retryable",
|
|
7
|
+
"provider_error",
|
|
8
|
+
"other",
|
|
9
|
+
"configuration_mismatch",
|
|
10
|
+
];
|
|
11
|
+
export function isPlayFailureKind(value) {
|
|
12
|
+
return playFailureKinds.some((kind) => kind === value);
|
|
13
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export function isProtagonistTemplateId(value) {
|
|
2
|
+
return (typeof value === "string" &&
|
|
3
|
+
/^template-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u.test(value));
|
|
4
|
+
}
|
|
5
|
+
export function isProtagonistTemplateContent(name, body) {
|
|
6
|
+
return (typeof name === "string" &&
|
|
7
|
+
name.trim().length > 0 &&
|
|
8
|
+
[...name].length <= 80 &&
|
|
9
|
+
!/[\r\n\u0085\u2028\u2029]/u.test(name) &&
|
|
10
|
+
typeof body === "string" &&
|
|
11
|
+
body.trim().length > 0 &&
|
|
12
|
+
new TextEncoder().encode(body).byteLength <= 16 * 1024);
|
|
13
|
+
}
|
package/dist/node/protocol/v1.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isAppReadingPreferences, } from "./appPreferences.js";
|
|
1
2
|
import { maxPortableContentArchiveBase64Characters } from "./contentTree.js";
|
|
2
3
|
export const v1Protocol = "narraeon.runtime/v1";
|
|
3
4
|
export class V1ProtocolError extends Error {
|
|
@@ -20,11 +21,15 @@ export function parseV1Envelope(value) {
|
|
|
20
21
|
}
|
|
21
22
|
const requiredFields = {
|
|
22
23
|
"preferences.save": {},
|
|
24
|
+
"protagonist-templates.save": { name: "string", body: "string" },
|
|
25
|
+
"protagonist-templates.copy": { id: "string" },
|
|
26
|
+
"protagonist-templates.delete": { id: "string" },
|
|
23
27
|
"model.save": { connection: "object" },
|
|
24
28
|
"model.copy": { connectionId: "string", name: "string" },
|
|
25
29
|
"model.select": { connectionId: "string" },
|
|
26
30
|
"model.delete": { connectionId: "string" },
|
|
27
31
|
"model.models": { provider: "string", baseUrl: "string" },
|
|
32
|
+
"content.create-sample": { locale: "string" },
|
|
28
33
|
"content.scripts.read": { packageId: "string" },
|
|
29
34
|
"content.scripts.set": { packageId: "string", enabled: "boolean" },
|
|
30
35
|
"world.package-scripts.read": { worldId: "string" },
|
|
@@ -82,8 +87,11 @@ const requiredFields = {
|
|
|
82
87
|
packageId: "string",
|
|
83
88
|
model: "object",
|
|
84
89
|
},
|
|
90
|
+
"world.archive.export": { worldId: "string", operationId: "string" },
|
|
91
|
+
"world.archive.import": { operationId: "string", uploadId: "string" },
|
|
85
92
|
"world.read": { worldId: "string" },
|
|
86
93
|
"world.rename": { worldId: "string", name: "string" },
|
|
94
|
+
"world.story.export": { worldId: "string", format: "string" },
|
|
87
95
|
"world.delete": { worldId: "string" },
|
|
88
96
|
"artifacts.read": { worldId: "string" },
|
|
89
97
|
"artifacts.debug": { worldId: "string" },
|
|
@@ -204,6 +212,14 @@ function validateRequestFields(request) {
|
|
|
204
212
|
if (!valid || (kind === "string" && value === "" && !allowsEmptyString))
|
|
205
213
|
throw new V1ProtocolError("invalid_request", `${String(request.type)}.${field} is invalid`);
|
|
206
214
|
}
|
|
215
|
+
if (request.type === "world.story.export" &&
|
|
216
|
+
request.format !== "markdown" &&
|
|
217
|
+
request.format !== "text")
|
|
218
|
+
throw new V1ProtocolError("invalid_request", "world.story.export.format must be markdown or text");
|
|
219
|
+
if (request.type === "content.create-sample" &&
|
|
220
|
+
(Object.keys(request).some((key) => key !== "type" && key !== "locale") ||
|
|
221
|
+
(request.locale !== "en" && request.locale !== "zh-CN")))
|
|
222
|
+
throw new V1ProtocolError("invalid_request", "content.create-sample.locale must be en or zh-CN, with no extra fields");
|
|
207
223
|
if (request.type === "world.extensions.set" &&
|
|
208
224
|
!["on", "off", "default"].includes(String(request.value)))
|
|
209
225
|
throw new V1ProtocolError("invalid_request", "Invalid world extension choice");
|
|
@@ -216,7 +232,7 @@ function validateRequestFields(request) {
|
|
|
216
232
|
request.locale !== "zh-CN")
|
|
217
233
|
throw new V1ProtocolError("invalid_request", "preferences.save.locale must be en or zh-CN");
|
|
218
234
|
if (request.reading !== undefined &&
|
|
219
|
-
!
|
|
235
|
+
!isAppReadingPreferences(request.reading))
|
|
220
236
|
throw new V1ProtocolError("invalid_request", "preferences.save.reading is invalid");
|
|
221
237
|
}
|
|
222
238
|
if (request.type === "content.import") {
|
|
@@ -359,6 +375,10 @@ function validateRequestFields(request) {
|
|
|
359
375
|
const requestTypes = new Set([
|
|
360
376
|
"workspace.read",
|
|
361
377
|
"preferences.read",
|
|
378
|
+
"protagonist-templates.read",
|
|
379
|
+
"protagonist-templates.save",
|
|
380
|
+
"protagonist-templates.copy",
|
|
381
|
+
"protagonist-templates.delete",
|
|
362
382
|
"preferences.save",
|
|
363
383
|
"model.read",
|
|
364
384
|
"model.save",
|
|
@@ -367,6 +387,7 @@ const requestTypes = new Set([
|
|
|
367
387
|
"model.delete",
|
|
368
388
|
"model.models",
|
|
369
389
|
"content.create",
|
|
390
|
+
"content.create-sample",
|
|
370
391
|
"content.scripts.read",
|
|
371
392
|
"content.scripts.set",
|
|
372
393
|
"world.package-scripts.read",
|
|
@@ -401,8 +422,11 @@ const requestTypes = new Set([
|
|
|
401
422
|
"play.workbench.read",
|
|
402
423
|
"prompt.preview",
|
|
403
424
|
"world.create",
|
|
425
|
+
"world.archive.export",
|
|
426
|
+
"world.archive.import",
|
|
404
427
|
"world.read",
|
|
405
428
|
"world.rename",
|
|
429
|
+
"world.story.export",
|
|
406
430
|
"world.delete",
|
|
407
431
|
"artifacts.read",
|
|
408
432
|
"artifacts.debug",
|
|
@@ -447,21 +471,3 @@ const requestTypes = new Set([
|
|
|
447
471
|
function isRecord(value) {
|
|
448
472
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
449
473
|
}
|
|
450
|
-
function isValidReadingPreferences(value) {
|
|
451
|
-
if (!isRecord(value))
|
|
452
|
-
return false;
|
|
453
|
-
return (Object.keys(value).length === 5 &&
|
|
454
|
-
(value.density === "compact" ||
|
|
455
|
-
value.density === "standard" ||
|
|
456
|
-
value.density === "relaxed") &&
|
|
457
|
-
isFiniteRange(value.fontSize, 15, 24) &&
|
|
458
|
-
isFiniteRange(value.lineHeight, 1.4, 2.4) &&
|
|
459
|
-
isFiniteRange(value.letterSpacing, 0, 0.12) &&
|
|
460
|
-
isFiniteRange(value.measure, 32, 72));
|
|
461
|
-
}
|
|
462
|
-
function isFiniteRange(value, minimum, maximum) {
|
|
463
|
-
return (typeof value === "number" &&
|
|
464
|
-
Number.isFinite(value) &&
|
|
465
|
-
value >= minimum &&
|
|
466
|
-
value <= maximum);
|
|
467
|
-
}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { WorldArchives } from "./archive/WorldArchives.js";
|
|
2
|
+
import { WorldArchiveError } from "./archive/WorldArchiveZip.js";
|
|
3
|
+
import { FileNativeWorldCreationError } from "./world/FileNativeWorldStore.js";
|
|
1
4
|
import { PackageScriptPermissions } from "./extension/PackageScriptPermissions.js";
|
|
2
5
|
import { comparePromptPrefixes } from "./prompt/WorldPromptDiagnostics.js";
|
|
3
6
|
import { join } from "node:path";
|
|
@@ -8,7 +11,7 @@ import { ContentPackageImportError, ContentWorkspace, InvalidContentTreeError, }
|
|
|
8
11
|
import { contentTreeFingerprint } from "./content/ContentTreeFingerprint.js";
|
|
9
12
|
import { inspectContentPackageCurrentTree } from "./content/FileNativeContentTree.js";
|
|
10
13
|
import { FileNativeModelHost } from "./model/FileNativeModelAdapters.js";
|
|
11
|
-
import {
|
|
14
|
+
import {} from "./model/ModelHost.js";
|
|
12
15
|
import { ModelConnectionStore } from "./model/ModelConnectionStore.js";
|
|
13
16
|
import { FileNativeContinuityCorrection } from "./play/FileNativeContinuityCorrection.js";
|
|
14
17
|
import { fingerprintControl } from "./play/PlayDocumentTools.js";
|
|
@@ -24,12 +27,14 @@ import { SettingImprovementRollbackError, SettingImprovementSession, } from "./s
|
|
|
24
27
|
import { FileNativeWorldStore } from "./world/FileNativeWorldStore.js";
|
|
25
28
|
import { WorldDocumentStore } from "./world/WorldDocumentStore.js";
|
|
26
29
|
import { FileNativeAiFailureLog } from "./model/AiFailureLog.js";
|
|
30
|
+
import { ProtagonistTemplateStore } from "./config/ProtagonistTemplateStore.js";
|
|
27
31
|
import { AppPreferencesStore } from "./config/AppPreferencesStore.js";
|
|
28
32
|
import { FileNativeWorldRevisionStore } from "./world-revision/FileNativeWorldRevisionStore.js";
|
|
29
33
|
import { WorldRevisionSession } from "./world-revision/WorldRevisionSession.js";
|
|
30
34
|
import { WorldRevisionWorkspace, WorldRevisionWorkspaceError, } from "./world-revision/WorldRevisionWorkspace.js";
|
|
31
35
|
import { ConversationChanges } from "./ConversationChanges.js";
|
|
32
36
|
export class V1Runtime {
|
|
37
|
+
worldArchives;
|
|
33
38
|
#conversationChanges = new ConversationChanges();
|
|
34
39
|
#content;
|
|
35
40
|
#playPresets;
|
|
@@ -40,12 +45,14 @@ export class V1Runtime {
|
|
|
40
45
|
#playCallChains;
|
|
41
46
|
#failureLog;
|
|
42
47
|
#preferences;
|
|
48
|
+
#protagonistTemplates;
|
|
43
49
|
#settingImprovements;
|
|
44
50
|
#worldRevisions;
|
|
45
51
|
#compiler = new FileNativePromptCompiler();
|
|
46
52
|
#locale = defaultAppLocale;
|
|
47
53
|
constructor(input) {
|
|
48
54
|
this.#preferences = new AppPreferencesStore(input.configRoot);
|
|
55
|
+
this.#protagonistTemplates = new ProtagonistTemplateStore(input.configRoot);
|
|
49
56
|
this.#content = new ContentWorkspace(input.dataRoot, {
|
|
50
57
|
locale: () => this.#locale,
|
|
51
58
|
});
|
|
@@ -59,6 +66,11 @@ export class V1Runtime {
|
|
|
59
66
|
? undefined
|
|
60
67
|
: new FileNativeAiFailureLog(join(input.logRoot, "ai-failures"));
|
|
61
68
|
this.#playCallChains = new PlayCallChain(this.#worlds, this.#compiler, this.#artifacts, this.#failureLog, (id) => this.#conversationChanges.publish(`play:${id}`));
|
|
69
|
+
this.worldArchives = new WorldArchives({
|
|
70
|
+
dataRoot: input.dataRoot,
|
|
71
|
+
worlds: this.#worlds,
|
|
72
|
+
chains: this.#playCallChains,
|
|
73
|
+
});
|
|
62
74
|
this.#models = new ModelConnectionStore(input.configRoot);
|
|
63
75
|
this.#corrections = new FileNativeContinuityCorrection(this.#worlds, {
|
|
64
76
|
compiler: this.#compiler,
|
|
@@ -133,16 +145,28 @@ export class V1Runtime {
|
|
|
133
145
|
this.#locale = (await this.#preferences.view()).locale;
|
|
134
146
|
this.#compiler.setLocale(this.#locale);
|
|
135
147
|
await this.#playPresets.initialize();
|
|
148
|
+
await this.worldArchives.initialize();
|
|
136
149
|
}
|
|
137
150
|
async handle(request, playCallChainObserver) {
|
|
138
151
|
try {
|
|
139
152
|
const result = await this.#dispatch(request, playCallChainObserver);
|
|
140
153
|
return { protocol: v1Protocol, result };
|
|
141
154
|
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
if (error instanceof WorldArchiveError)
|
|
157
|
+
throw new V1ProtocolError(error.code, error.message, { cause: error });
|
|
158
|
+
if (request.type === "world.archive.export" &&
|
|
159
|
+
error instanceof FileNativeWorldCreationError &&
|
|
160
|
+
(error.code === "operation_conflict" ||
|
|
161
|
+
error.code === "inconsistent_materialization"))
|
|
162
|
+
throw new V1ProtocolError(error.code, error.message, { cause: error });
|
|
163
|
+
throw error;
|
|
164
|
+
}
|
|
142
165
|
finally {
|
|
143
166
|
// Explicit commands may partially settle before failing. Readers recover
|
|
144
167
|
// through the same Runtime projection, never infer success from this signal.
|
|
145
|
-
if (
|
|
168
|
+
if (request.type !== "world.story.export" &&
|
|
169
|
+
!/\.(read|status|overview|inspect|page|detail|list|preview)$/.test(request.type)) {
|
|
146
170
|
if ("packageId" in request)
|
|
147
171
|
this.#conversationChanges.publish(`setting:${request.packageId}`);
|
|
148
172
|
if ("worldId" in request) {
|
|
@@ -213,6 +237,14 @@ export class V1Runtime {
|
|
|
213
237
|
await this.#playPresets.syncBuiltinDefaultLocale();
|
|
214
238
|
return preferences;
|
|
215
239
|
}
|
|
240
|
+
case "protagonist-templates.read":
|
|
241
|
+
return this.#protagonistTemplates.view();
|
|
242
|
+
case "protagonist-templates.save":
|
|
243
|
+
return this.#protagonistTemplates.save(request);
|
|
244
|
+
case "protagonist-templates.copy":
|
|
245
|
+
return this.#protagonistTemplates.copy(request.id);
|
|
246
|
+
case "protagonist-templates.delete":
|
|
247
|
+
return this.#protagonistTemplates.delete(request.id);
|
|
216
248
|
case "model.read":
|
|
217
249
|
return this.#models.view();
|
|
218
250
|
case "model.save":
|
|
@@ -227,6 +259,8 @@ export class V1Runtime {
|
|
|
227
259
|
return this.#models.listModels(request);
|
|
228
260
|
case "content.create":
|
|
229
261
|
return this.#content.createCurrentTreeContentPackage();
|
|
262
|
+
case "content.create-sample":
|
|
263
|
+
return this.#content.createSampleContentPackage(request.locale);
|
|
230
264
|
case "content.scripts.read":
|
|
231
265
|
return this.#content.packageScriptPermissions(request.packageId);
|
|
232
266
|
case "content.scripts.set":
|
|
@@ -402,12 +436,18 @@ export class V1Runtime {
|
|
|
402
436
|
return this.#worlds.getCreationOutcome(request.operationId);
|
|
403
437
|
case "world.rename":
|
|
404
438
|
return this.#worlds.renameWorld(request.worldId, request.name);
|
|
439
|
+
case "world.story.export":
|
|
440
|
+
return this.#worlds.exportStory(request.worldId, request.format, this.#locale);
|
|
405
441
|
case "world.delete":
|
|
406
442
|
return playCall(async () => {
|
|
407
443
|
await this.#worlds.assertWorldRevisionUnlocked(request.worldId);
|
|
408
444
|
this.#playCallChains.forgetWorld(request.worldId);
|
|
409
445
|
return this.#worlds.deleteWorld(request.worldId);
|
|
410
446
|
});
|
|
447
|
+
case "world.archive.export":
|
|
448
|
+
return this.worldArchives.exportWorld(request);
|
|
449
|
+
case "world.archive.import":
|
|
450
|
+
return this.worldArchives.importWorld(request);
|
|
411
451
|
case "world.read": {
|
|
412
452
|
const head = await this.#worlds.currentHead(request.worldId);
|
|
413
453
|
const playerViews = await this.#worlds.renderPlayerViewsAtHead(request.worldId, head);
|
|
@@ -435,6 +475,7 @@ export class V1Runtime {
|
|
|
435
475
|
playCallChain: null,
|
|
436
476
|
playTimeline,
|
|
437
477
|
worldRevision: revisionEpoch,
|
|
478
|
+
archiveNeedsFreshContext: await this.#worlds.archiveNeedsFreshContext(request.worldId),
|
|
438
479
|
};
|
|
439
480
|
}
|
|
440
481
|
case "world.surface.read":
|
|
@@ -542,7 +583,7 @@ export class V1Runtime {
|
|
|
542
583
|
hostPresetId: preset.id,
|
|
543
584
|
});
|
|
544
585
|
await this.#reconcileResultArtifacts(result);
|
|
545
|
-
return result;
|
|
586
|
+
return { outcome: result.outcome, world: result.world };
|
|
546
587
|
}
|
|
547
588
|
case "play.chain.revise-player": {
|
|
548
589
|
const base = {
|
|
@@ -634,8 +675,7 @@ export class V1Runtime {
|
|
|
634
675
|
const frozenBinding = persisted?.value.modelBinding;
|
|
635
676
|
const continueContext = currentContext !== null &&
|
|
636
677
|
!currentContext.stale &&
|
|
637
|
-
frozenBinding
|
|
638
|
-
equalModelHostBinding(frozenBinding, modelBinding);
|
|
678
|
+
frozenBinding?.provider === modelBinding.provider;
|
|
639
679
|
const baseline = continueContext
|
|
640
680
|
? await this.#worlds.bindPlayCallChainAt(request.worldId, currentContext.baselineHead)
|
|
641
681
|
: binding;
|
|
@@ -928,7 +968,7 @@ async function playCall(operation) {
|
|
|
928
968
|
}
|
|
929
969
|
catch (error) {
|
|
930
970
|
if (error instanceof PlayCallChainError)
|
|
931
|
-
throw new V1ProtocolError(
|
|
971
|
+
throw new V1ProtocolError(error.code, error.message, {
|
|
932
972
|
cause: error,
|
|
933
973
|
});
|
|
934
974
|
throw error;
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { createReadStream, createWriteStream } from "node:fs";
|
|
3
|
+
import { mkdir, readdir, stat } from "node:fs/promises";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { Transform } from "node:stream";
|
|
6
|
+
import { pipeline } from "node:stream/promises";
|
|
7
|
+
import * as yauzl from "yauzl";
|
|
8
|
+
import * as yazl from "yazl";
|
|
9
|
+
export class WorldArchiveError extends Error {
|
|
10
|
+
code;
|
|
11
|
+
constructor(code, message, options) {
|
|
12
|
+
super(message, options);
|
|
13
|
+
this.code = code;
|
|
14
|
+
this.name = "WorldArchiveError";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export function archivePath(path) {
|
|
18
|
+
if (!path ||
|
|
19
|
+
path.startsWith("/") ||
|
|
20
|
+
Buffer.byteLength(path, "utf8") > 4096 ||
|
|
21
|
+
path.includes("\\") ||
|
|
22
|
+
Array.from(path).some((character) => character.charCodeAt(0) < 32 ||
|
|
23
|
+
character.charCodeAt(0) === 127 ||
|
|
24
|
+
'<>:"|?*'.includes(character)) ||
|
|
25
|
+
path
|
|
26
|
+
.split("/")
|
|
27
|
+
.some((part) => !part ||
|
|
28
|
+
part === "." ||
|
|
29
|
+
part === ".." ||
|
|
30
|
+
/[. ]$/u.test(part) ||
|
|
31
|
+
Buffer.byteLength(part, "utf8") > 255 ||
|
|
32
|
+
/^(?:AUX|CON|NUL|PRN|COM[1-9]|LPT[1-9])$/u.test(part.split(".", 1)[0].toUpperCase())))
|
|
33
|
+
throw new WorldArchiveError("unsafe_path", "The archive contains an unsafe path");
|
|
34
|
+
}
|
|
35
|
+
/** No whole-file buffers: hash, limit and persist in the same backpressured pipeline. */
|
|
36
|
+
export async function streamFile(source, destination, maxBytes) {
|
|
37
|
+
let bytes = 0;
|
|
38
|
+
const hash = createHash("sha256");
|
|
39
|
+
await mkdir(dirname(destination), { recursive: true, mode: 0o700 });
|
|
40
|
+
const counter = new Transform({
|
|
41
|
+
transform(chunk, _encoding, callback) {
|
|
42
|
+
bytes += chunk.length;
|
|
43
|
+
if (bytes > maxBytes)
|
|
44
|
+
callback(new WorldArchiveError("limit_exceeded", "The archive exceeds its size limit"));
|
|
45
|
+
else {
|
|
46
|
+
hash.update(chunk);
|
|
47
|
+
callback(null, chunk);
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
await pipeline(source, counter, createWriteStream(destination, { flags: "wx", mode: 0o600 }));
|
|
52
|
+
return { bytes, sha256: hash.digest("hex") };
|
|
53
|
+
}
|
|
54
|
+
export async function inventory(root, prefix = "world") {
|
|
55
|
+
const result = [];
|
|
56
|
+
async function visit(directory, path) {
|
|
57
|
+
for (const entry of (await readdir(directory, { withFileTypes: true })).sort((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
58
|
+
const full = join(directory, entry.name);
|
|
59
|
+
const relative = `${path}/${entry.name}`;
|
|
60
|
+
archivePath(relative);
|
|
61
|
+
if (entry.isDirectory())
|
|
62
|
+
await visit(full, relative);
|
|
63
|
+
else if (entry.isFile()) {
|
|
64
|
+
const hash = createHash("sha256");
|
|
65
|
+
let bytes = 0;
|
|
66
|
+
for await (const chunk of createReadStream(full)) {
|
|
67
|
+
const buffer = chunk;
|
|
68
|
+
if (!Buffer.isBuffer(buffer))
|
|
69
|
+
throw new Error("Archive file stream must be binary");
|
|
70
|
+
bytes += buffer.length;
|
|
71
|
+
hash.update(buffer);
|
|
72
|
+
}
|
|
73
|
+
result.push({ path: relative, bytes, sha256: hash.digest("hex") });
|
|
74
|
+
}
|
|
75
|
+
else
|
|
76
|
+
throw new WorldArchiveError("unsafe_path", "Only files and directories may be archived");
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
await visit(root, prefix);
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
export async function writeArchive(root, files, destination, limits) {
|
|
83
|
+
const manifestBytes = (await stat(join(root, "narraeon-world.json"))).size;
|
|
84
|
+
if (manifestBytes > limits.entryBytes ||
|
|
85
|
+
files.length + 1 > limits.entries ||
|
|
86
|
+
files.some((file) => file.bytes > limits.entryBytes) ||
|
|
87
|
+
files.reduce((sum, file) => sum + file.bytes, manifestBytes) >
|
|
88
|
+
limits.extractedBytes)
|
|
89
|
+
throw new WorldArchiveError("limit_exceeded", "The world exceeds archive limits");
|
|
90
|
+
const zip = new yazl.ZipFile();
|
|
91
|
+
const output = streamFile(zip.outputStream, destination, limits.archiveBytes);
|
|
92
|
+
zip.on("error", (error) => zip.outputStream.destroy(error));
|
|
93
|
+
zip.addFile(join(root, "narraeon-world.json"), "narraeon-world.json");
|
|
94
|
+
for (const file of files)
|
|
95
|
+
zip.addFile(join(root, file.path), file.path);
|
|
96
|
+
zip.end();
|
|
97
|
+
return (await output).bytes;
|
|
98
|
+
}
|
|
99
|
+
export async function extractArchive(archive, destination, limits) {
|
|
100
|
+
if ((await stat(archive)).size > limits.archiveBytes)
|
|
101
|
+
throw new WorldArchiveError("limit_exceeded", "The ZIP exceeds its size limit");
|
|
102
|
+
let zip;
|
|
103
|
+
try {
|
|
104
|
+
zip = await new Promise((resolve, reject) => yauzl.open(archive, { lazyEntries: true, decodeStrings: false, validateEntrySizes: true }, (error, value) => (error ? reject(error) : resolve(value))));
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
throw new WorldArchiveError("invalid_archive", "Invalid ZIP archive", {
|
|
108
|
+
cause: error,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
const files = new Map();
|
|
112
|
+
const paths = new Map();
|
|
113
|
+
let total = 0;
|
|
114
|
+
let count = 0;
|
|
115
|
+
const register = (path, directory, explicit) => {
|
|
116
|
+
const key = path.normalize("NFKC").toLowerCase();
|
|
117
|
+
const previous = paths.get(key);
|
|
118
|
+
if (previous &&
|
|
119
|
+
(previous.path !== path ||
|
|
120
|
+
previous.directory !== directory ||
|
|
121
|
+
(explicit && previous.explicit)))
|
|
122
|
+
throw new WorldArchiveError("unsafe_path", "Duplicate or conflicting archive paths");
|
|
123
|
+
paths.set(key, {
|
|
124
|
+
path,
|
|
125
|
+
directory,
|
|
126
|
+
explicit: explicit || previous?.explicit === true,
|
|
127
|
+
});
|
|
128
|
+
};
|
|
129
|
+
try {
|
|
130
|
+
if (zip.entryCount > limits.entries)
|
|
131
|
+
throw new WorldArchiveError("limit_exceeded", "Too many ZIP entries");
|
|
132
|
+
await new Promise((resolve, reject) => {
|
|
133
|
+
zip.on("error", reject);
|
|
134
|
+
zip.on("end", resolve);
|
|
135
|
+
zip.on("entry", (entry) => {
|
|
136
|
+
void (async () => {
|
|
137
|
+
if (++count > limits.entries)
|
|
138
|
+
throw new WorldArchiveError("limit_exceeded", "Too many ZIP entries");
|
|
139
|
+
let raw;
|
|
140
|
+
try {
|
|
141
|
+
raw = new TextDecoder("utf-8", { fatal: true }).decode(entry.fileNameRaw);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
throw new WorldArchiveError("unsafe_path", "ZIP paths must be UTF-8");
|
|
145
|
+
}
|
|
146
|
+
const directory = raw.endsWith("/");
|
|
147
|
+
const path = directory ? raw.slice(0, -1) : raw;
|
|
148
|
+
archivePath(path);
|
|
149
|
+
const mode = (entry.externalFileAttributes >>> 16) & 0o170000;
|
|
150
|
+
if ((mode !== 0 && mode !== (directory ? 0o040000 : 0o100000)) ||
|
|
151
|
+
((entry.externalFileAttributes & 0x10) !== 0 && !directory))
|
|
152
|
+
throw new WorldArchiveError("unsafe_path", "Only regular ZIP files and directories are allowed");
|
|
153
|
+
if (entry.isEncrypted() || ![0, 8].includes(entry.compressionMethod))
|
|
154
|
+
throw new WorldArchiveError("invalid_archive", "Unsupported ZIP encoding");
|
|
155
|
+
const segments = path.split("/");
|
|
156
|
+
for (let index = 1; index < segments.length; index++)
|
|
157
|
+
register(segments.slice(0, index).join("/"), true, false);
|
|
158
|
+
register(path, directory, true);
|
|
159
|
+
if (path !== "world" &&
|
|
160
|
+
!path.startsWith("world/") &&
|
|
161
|
+
path !== "narraeon-world.json")
|
|
162
|
+
throw new WorldArchiveError("invalid_archive", "Unexpected archive entry");
|
|
163
|
+
if ((path === "world" && !directory) ||
|
|
164
|
+
(path === "narraeon-world.json" && directory))
|
|
165
|
+
throw new WorldArchiveError("invalid_archive", "Invalid archive layout");
|
|
166
|
+
if (entry.uncompressedSize > limits.entryBytes ||
|
|
167
|
+
total + entry.uncompressedSize > limits.extractedBytes)
|
|
168
|
+
throw new WorldArchiveError("limit_exceeded", "Extracted ZIP exceeds its size limit");
|
|
169
|
+
if (directory) {
|
|
170
|
+
if (entry.uncompressedSize !== 0)
|
|
171
|
+
throw new WorldArchiveError("invalid_archive", "ZIP directory contains data");
|
|
172
|
+
await mkdir(join(destination, path), {
|
|
173
|
+
recursive: true,
|
|
174
|
+
mode: 0o700,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
const stream = await new Promise((resolveStream, rejectStream) => zip.openReadStream(entry, (error, value) => error ? rejectStream(error) : resolveStream(value)));
|
|
179
|
+
const result = await streamFile(stream, join(destination, path), Math.min(limits.entryBytes, limits.extractedBytes - total));
|
|
180
|
+
if (result.bytes !== entry.uncompressedSize)
|
|
181
|
+
throw new WorldArchiveError("integrity_failed", "ZIP entry size does not match");
|
|
182
|
+
total += result.bytes;
|
|
183
|
+
files.set(path, { path, ...result });
|
|
184
|
+
}
|
|
185
|
+
zip.readEntry();
|
|
186
|
+
})().catch(reject);
|
|
187
|
+
});
|
|
188
|
+
zip.readEntry();
|
|
189
|
+
});
|
|
190
|
+
return files;
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
if (error instanceof WorldArchiveError)
|
|
194
|
+
throw error;
|
|
195
|
+
throw new WorldArchiveError("invalid_archive", "Invalid ZIP archive", {
|
|
196
|
+
cause: error,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
finally {
|
|
200
|
+
zip.close();
|
|
201
|
+
}
|
|
202
|
+
}
|