narraeon 0.2.0 → 0.3.1
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/dist/node/protocol/v1.js +6 -0
- package/dist/node/runtime/FileNativePhysicalClone.js +86 -0
- package/dist/node/runtime/V1Runtime.js +7 -6
- package/dist/node/runtime/model/FileNativeModelAdapters.js +40 -13
- package/dist/node/runtime/model/ModelHost.js +7 -0
- package/dist/node/runtime/play/FileNativePlayTimelineStore.js +96 -7
- package/dist/node/runtime/play/PlayCallChain.js +309 -53
- package/dist/node/runtime/play/PlayFollowupRequests.js +42 -22
- package/dist/node/runtime/prompt/FileNativePromptCompiler.js +5 -5
- package/dist/node/runtime/world/FileNativeAuthorityV3.js +971 -0
- package/dist/node/runtime/world/FileNativeHistoryProjection.js +39 -0
- package/dist/node/runtime/world/FileNativeWorldStorageMigrator.js +454 -6
- package/dist/node/runtime/world/FileNativeWorldStore.js +423 -850
- package/dist/node/server/createServer.js +10 -2
- package/dist/web/assets/index-D5VqDvjY.js +1192 -0
- package/dist/web/assets/index-Xa0s35CJ.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-DHV_VWl3.js +0 -1192
- package/dist/web/assets/index-DjLCkP99.css +0 -1
|
@@ -0,0 +1,971 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { link, mkdir, open, readFile, readdir, rename, rm, writeFile, } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { isDeepStrictEqual } from "node:util";
|
|
5
|
+
import { cloneFilePhysically } from "../FileNativePhysicalClone.js";
|
|
6
|
+
export const continuityHeadFile = "continuity-head.json";
|
|
7
|
+
export const authorityV3Directory = "authority-v3";
|
|
8
|
+
export class FileNativeAuthorityV3Error extends Error {
|
|
9
|
+
code;
|
|
10
|
+
constructor(code, message) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = "FileNativeAuthorityV3Error";
|
|
13
|
+
this.code = code;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* World-neutral immutable Authority facts with one target-local mutable head.
|
|
18
|
+
*
|
|
19
|
+
* Every sequence owns an immutable epoch directory. A fork can therefore
|
|
20
|
+
* retain a prefix by cloning directory entries without parsing or rewriting
|
|
21
|
+
* ancestor facts. Object references include their creation epoch so unchanged
|
|
22
|
+
* state/history/material objects remain directly addressable after a fork.
|
|
23
|
+
*/
|
|
24
|
+
export class FileNativeAuthorityV3 {
|
|
25
|
+
#runtimeRoot;
|
|
26
|
+
#authorityRoot;
|
|
27
|
+
constructor(worldRoot) {
|
|
28
|
+
this.#runtimeRoot = join(worldRoot, "runtime");
|
|
29
|
+
this.#authorityRoot = join(this.#runtimeRoot, authorityV3Directory);
|
|
30
|
+
}
|
|
31
|
+
async exists() {
|
|
32
|
+
try {
|
|
33
|
+
await readFile(join(this.#runtimeRoot, continuityHeadFile), "utf8");
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (isNodeError(error) && error.code === "ENOENT")
|
|
38
|
+
return false;
|
|
39
|
+
throw error;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
async initialize(input) {
|
|
43
|
+
const epoch = 0;
|
|
44
|
+
const state = await this.#writeStateManifest(epoch, input.state);
|
|
45
|
+
const historySegment = {
|
|
46
|
+
schemaVersion: 3,
|
|
47
|
+
type: "file_native_history_segment",
|
|
48
|
+
previous: null,
|
|
49
|
+
messages: structuredClone(input.history),
|
|
50
|
+
};
|
|
51
|
+
assertHistorySegment(historySegment);
|
|
52
|
+
assertGenesisHistory(historySegment.messages);
|
|
53
|
+
const history = input.history.length === 0
|
|
54
|
+
? null
|
|
55
|
+
: await this.#writeObject(epoch, "history.json", historySegment);
|
|
56
|
+
const materialSet = {
|
|
57
|
+
schemaVersion: 3,
|
|
58
|
+
type: "file_native_material_set",
|
|
59
|
+
items: structuredClone(input.additionalMaterials),
|
|
60
|
+
};
|
|
61
|
+
assertMaterialSet(materialSet);
|
|
62
|
+
const materials = await this.#writeObject(epoch, "materials.json", materialSet);
|
|
63
|
+
const endpointValue = {
|
|
64
|
+
schemaVersion: 3,
|
|
65
|
+
type: "file_native_authority_endpoint",
|
|
66
|
+
state,
|
|
67
|
+
history,
|
|
68
|
+
historyLength: input.history.length,
|
|
69
|
+
materials,
|
|
70
|
+
};
|
|
71
|
+
assertEndpoint(endpointValue);
|
|
72
|
+
const endpoint = await this.#writeObject(epoch, "endpoint.json", endpointValue);
|
|
73
|
+
const genesisValue = {
|
|
74
|
+
schemaVersion: 3,
|
|
75
|
+
type: "file_native_authority_genesis",
|
|
76
|
+
result: endpoint,
|
|
77
|
+
};
|
|
78
|
+
assertGenesis(genesisValue);
|
|
79
|
+
const genesis = await this.#writeObject(epoch, "authority.json", genesisValue);
|
|
80
|
+
const head = {
|
|
81
|
+
schemaVersion: 3,
|
|
82
|
+
type: "file_native_continuity_head",
|
|
83
|
+
head: "genesis",
|
|
84
|
+
sequence: 0,
|
|
85
|
+
genesisDigest: genesis.digest,
|
|
86
|
+
commitDigest: null,
|
|
87
|
+
result: endpoint,
|
|
88
|
+
operationId: input.operationId,
|
|
89
|
+
};
|
|
90
|
+
await publishJson(join(this.#runtimeRoot, continuityHeadFile), head);
|
|
91
|
+
return head;
|
|
92
|
+
}
|
|
93
|
+
async readHead() {
|
|
94
|
+
const value = await readJson(join(this.#runtimeRoot, continuityHeadFile));
|
|
95
|
+
assertHead(value);
|
|
96
|
+
return value;
|
|
97
|
+
}
|
|
98
|
+
async currentOperationId() {
|
|
99
|
+
return (await this.readHead()).operationId;
|
|
100
|
+
}
|
|
101
|
+
async discardUnacceptedNextEpoch() {
|
|
102
|
+
const current = await this.readHead();
|
|
103
|
+
const epochsRoot = join(this.#authorityRoot, "epochs");
|
|
104
|
+
await rm(this.#epochRoot(current.sequence + 1), {
|
|
105
|
+
recursive: true,
|
|
106
|
+
force: true,
|
|
107
|
+
});
|
|
108
|
+
await syncDirectory(epochsRoot);
|
|
109
|
+
}
|
|
110
|
+
async prepareAppend(input) {
|
|
111
|
+
const current = await this.readHead();
|
|
112
|
+
if (current.head !== input.parentHead)
|
|
113
|
+
throw new FileNativeAuthorityV3Error("stale", "The parent endpoint of the Authority operation has changed");
|
|
114
|
+
const sequence = current.sequence + 1;
|
|
115
|
+
const head = `commit:${sequence}`;
|
|
116
|
+
const basisHead = input.timelineParentHead ?? input.parentHead;
|
|
117
|
+
const basisResult = await this.#resultRefAt(basisHead);
|
|
118
|
+
const basis = await this.#readEndpoint(basisResult);
|
|
119
|
+
const state = await this.#nextState(sequence, basis.state, input.stateChanges);
|
|
120
|
+
const historyAppend = input.historyAppend.map((message, index) => ({
|
|
121
|
+
messageId: `message.${sequence}.${index + 1}.${message.role}`,
|
|
122
|
+
...structuredClone(message),
|
|
123
|
+
}));
|
|
124
|
+
const history = historyAppend.length === 0
|
|
125
|
+
? basis.history
|
|
126
|
+
: await this.#writeHistorySegment(sequence, {
|
|
127
|
+
schemaVersion: 3,
|
|
128
|
+
type: "file_native_history_segment",
|
|
129
|
+
previous: basis.history,
|
|
130
|
+
messages: historyAppend,
|
|
131
|
+
});
|
|
132
|
+
const basisMaterials = await this.#readMaterials(basis.materials);
|
|
133
|
+
const nextMaterialSet = {
|
|
134
|
+
schemaVersion: 3,
|
|
135
|
+
type: "file_native_material_set",
|
|
136
|
+
items: structuredClone(input.nextMaterials),
|
|
137
|
+
};
|
|
138
|
+
assertMaterialSet(nextMaterialSet);
|
|
139
|
+
const materials = isDeepStrictEqual(basisMaterials.items, input.nextMaterials)
|
|
140
|
+
? basis.materials
|
|
141
|
+
: await this.#writeObject(sequence, "materials.json", nextMaterialSet);
|
|
142
|
+
const endpointValue = {
|
|
143
|
+
schemaVersion: 3,
|
|
144
|
+
type: "file_native_authority_endpoint",
|
|
145
|
+
state,
|
|
146
|
+
history,
|
|
147
|
+
historyLength: basis.historyLength + historyAppend.length,
|
|
148
|
+
materials,
|
|
149
|
+
};
|
|
150
|
+
assertEndpoint(endpointValue);
|
|
151
|
+
const endpoint = await this.#writeObject(sequence, "endpoint.json", endpointValue);
|
|
152
|
+
const storedChanges = [];
|
|
153
|
+
for (const change of input.stateChanges) {
|
|
154
|
+
const blob = await this.#writeBlob(sequence, change.canonicalNextBytes);
|
|
155
|
+
storedChanges.push({
|
|
156
|
+
kind: change.kind,
|
|
157
|
+
documentId: change.documentId,
|
|
158
|
+
stableShortRef: change.stableShortRef,
|
|
159
|
+
relativePath: change.relativePath,
|
|
160
|
+
codec: change.codec,
|
|
161
|
+
expectedPreviousHash: change.expectedPreviousHash,
|
|
162
|
+
nextHash: change.nextHash,
|
|
163
|
+
nextBlob: blob,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
const auditParent = {
|
|
167
|
+
head: current.head,
|
|
168
|
+
digest: current.commitDigest ?? current.genesisDigest,
|
|
169
|
+
};
|
|
170
|
+
const timelineParent = await this.#authorityRefAt(basisHead);
|
|
171
|
+
const commit = {
|
|
172
|
+
schemaVersion: 3,
|
|
173
|
+
type: "file_native_authority_commit",
|
|
174
|
+
sequence,
|
|
175
|
+
head,
|
|
176
|
+
auditParent,
|
|
177
|
+
timelineParent,
|
|
178
|
+
mode: input.mode,
|
|
179
|
+
historyAppend,
|
|
180
|
+
stateChanges: storedChanges,
|
|
181
|
+
nextAdditionalMaterials: structuredClone(input.nextMaterials),
|
|
182
|
+
result: endpoint,
|
|
183
|
+
...(input.correctionTargets === undefined
|
|
184
|
+
? {}
|
|
185
|
+
: { correctionTargets: structuredClone(input.correctionTargets) }),
|
|
186
|
+
...(input.corrects === undefined ? {} : { corrects: input.corrects }),
|
|
187
|
+
...(input.timelineRevision === undefined
|
|
188
|
+
? {}
|
|
189
|
+
: { timelineRevision: structuredClone(input.timelineRevision) }),
|
|
190
|
+
};
|
|
191
|
+
assertCommit(commit);
|
|
192
|
+
const authority = await this.#writeObject(sequence, "authority.json", commit);
|
|
193
|
+
return {
|
|
194
|
+
commit,
|
|
195
|
+
commitDigest: authority.digest,
|
|
196
|
+
nextHead: {
|
|
197
|
+
schemaVersion: 3,
|
|
198
|
+
type: "file_native_continuity_head",
|
|
199
|
+
head,
|
|
200
|
+
sequence,
|
|
201
|
+
genesisDigest: current.genesisDigest,
|
|
202
|
+
commitDigest: authority.digest,
|
|
203
|
+
result: endpoint,
|
|
204
|
+
operationId: input.operationId,
|
|
205
|
+
},
|
|
206
|
+
stateChanges: structuredClone(input.stateChanges),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
async publishPrepared(prepared) {
|
|
210
|
+
const current = await this.readHead();
|
|
211
|
+
if (current.sequence + 1 !== prepared.nextHead.sequence ||
|
|
212
|
+
current.head !== prepared.commit.auditParent.head ||
|
|
213
|
+
(current.commitDigest ?? current.genesisDigest) !==
|
|
214
|
+
prepared.commit.auditParent.digest)
|
|
215
|
+
throw new FileNativeAuthorityV3Error("stale", "The prepared Authority fact no longer extends the current endpoint");
|
|
216
|
+
await publishJson(join(this.#runtimeRoot, continuityHeadFile), prepared.nextHead);
|
|
217
|
+
}
|
|
218
|
+
async recover(requestedHead) {
|
|
219
|
+
const current = await this.readHead();
|
|
220
|
+
const head = requestedHead ?? current.head;
|
|
221
|
+
const result = await this.#resultRefAt(head);
|
|
222
|
+
return this.#recoverResult(head, result);
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Recover the endpoint directly from the accepted small root. Fork staging
|
|
226
|
+
* uses this path so adopting the current head never decodes or rehashes its
|
|
227
|
+
* Authority fact; full audit remains available through readHistory().
|
|
228
|
+
*/
|
|
229
|
+
async recoverHeadResult() {
|
|
230
|
+
const current = await this.readHead();
|
|
231
|
+
return this.#recoverResult(current.head, current.result);
|
|
232
|
+
}
|
|
233
|
+
async #recoverResult(head, result) {
|
|
234
|
+
const endpoint = await this.#readEndpoint(result);
|
|
235
|
+
const state = await this.#recoverState(endpoint.state);
|
|
236
|
+
const history = await this.#recoverHistory(endpoint.history, endpoint.historyLength);
|
|
237
|
+
const materials = await this.#readMaterials(endpoint.materials);
|
|
238
|
+
return {
|
|
239
|
+
head,
|
|
240
|
+
state,
|
|
241
|
+
history,
|
|
242
|
+
additionalMaterials: structuredClone([...materials.items]),
|
|
243
|
+
result,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
async recoverState(head) {
|
|
247
|
+
const current = await this.readHead();
|
|
248
|
+
const selected = head ?? current.head;
|
|
249
|
+
const result = await this.#resultRefAt(selected);
|
|
250
|
+
const endpoint = await this.#readEndpoint(result);
|
|
251
|
+
return this.#recoverState(endpoint.state);
|
|
252
|
+
}
|
|
253
|
+
async recoverMaterials(head) {
|
|
254
|
+
const current = await this.readHead();
|
|
255
|
+
const selected = head ?? current.head;
|
|
256
|
+
const result = await this.#resultRefAt(selected);
|
|
257
|
+
const endpoint = await this.#readEndpoint(result);
|
|
258
|
+
const materials = await this.#readMaterials(endpoint.materials);
|
|
259
|
+
return structuredClone([...materials.items]);
|
|
260
|
+
}
|
|
261
|
+
async readHistory() {
|
|
262
|
+
const head = await this.readHead();
|
|
263
|
+
const genesis = await this.#readObject({ epoch: 0, digest: head.genesisDigest }, "authority.json", "file_native_authority_genesis");
|
|
264
|
+
assertGenesis(genesis);
|
|
265
|
+
await this.#assertAcceptedGenesis(head);
|
|
266
|
+
const commits = [];
|
|
267
|
+
let expectedHead = "genesis";
|
|
268
|
+
let expectedDigest = head.genesisDigest;
|
|
269
|
+
for (let sequence = 1; sequence <= head.sequence; sequence += 1) {
|
|
270
|
+
const { value: commit, digest } = await this.#readAuthorityCommit(sequence);
|
|
271
|
+
if (commit.sequence !== sequence ||
|
|
272
|
+
commit.head !== `commit:${sequence}` ||
|
|
273
|
+
commit.auditParent.head !== expectedHead ||
|
|
274
|
+
commit.auditParent.digest !== expectedDigest)
|
|
275
|
+
throw new FileNativeAuthorityV3Error("corrupt", `Authority fact ${sequence} does not extend the immutable audit chain`);
|
|
276
|
+
commits.push(commit);
|
|
277
|
+
expectedHead = commit.head;
|
|
278
|
+
expectedDigest = digest;
|
|
279
|
+
}
|
|
280
|
+
if (expectedHead !== head.head ||
|
|
281
|
+
(head.sequence === 0 ? null : expectedDigest) !== head.commitDigest ||
|
|
282
|
+
!isDeepStrictEqual(head.result, commits.at(-1)?.result ?? genesis.result))
|
|
283
|
+
throw new FileNativeAuthorityV3Error("corrupt", "Continuity head does not match the immutable Authority chain");
|
|
284
|
+
return { head: head.head, commits };
|
|
285
|
+
}
|
|
286
|
+
async commitAt(head) {
|
|
287
|
+
if (head === "genesis")
|
|
288
|
+
return null;
|
|
289
|
+
const sequence = parseHeadSequence(head);
|
|
290
|
+
const current = await this.readHead();
|
|
291
|
+
const { value, digest } = await this.#readAuthorityCommit(sequence);
|
|
292
|
+
if (value.head !== head)
|
|
293
|
+
throw new FileNativeAuthorityV3Error("corrupt", `Authority endpoint does not exist: ${head}`);
|
|
294
|
+
await this.#assertAcceptedAuthorityRef(current, sequence, head, digest);
|
|
295
|
+
return value;
|
|
296
|
+
}
|
|
297
|
+
async authorityFactAt(head) {
|
|
298
|
+
if (head === "genesis")
|
|
299
|
+
throw new FileNativeAuthorityV3Error("conflict", "Genesis is not an Authority commit");
|
|
300
|
+
const sequence = parseHeadSequence(head);
|
|
301
|
+
const current = await this.readHead();
|
|
302
|
+
const { value, digest } = await this.#readAuthorityCommit(sequence);
|
|
303
|
+
if (value.head !== head)
|
|
304
|
+
throw new FileNativeAuthorityV3Error("corrupt", `Authority endpoint identity is invalid: ${head}`);
|
|
305
|
+
await this.#assertAcceptedAuthorityRef(current, sequence, head, digest);
|
|
306
|
+
return { commit: value, digest };
|
|
307
|
+
}
|
|
308
|
+
async clonePrefixTo(input) {
|
|
309
|
+
const sourceHead = await this.readHead();
|
|
310
|
+
const selectedSequence = input.selectedHead === "genesis"
|
|
311
|
+
? 0
|
|
312
|
+
: parseHeadSequence(input.selectedHead);
|
|
313
|
+
if (selectedSequence > sourceHead.sequence)
|
|
314
|
+
throw new FileNativeAuthorityV3Error("conflict", `Cannot fork a nonexistent Authority endpoint: ${input.selectedHead}`);
|
|
315
|
+
const selected = input.selectedHead === sourceHead.head
|
|
316
|
+
? { ...structuredClone(sourceHead), operationId: input.operationId }
|
|
317
|
+
: selectedSequence === 0
|
|
318
|
+
? await this.#headAtGenesis(sourceHead, input.operationId)
|
|
319
|
+
: await this.#headAtCommit(sourceHead, selectedSequence, input.operationId);
|
|
320
|
+
const targetRuntime = join(input.targetWorldRoot, "runtime");
|
|
321
|
+
const targetAuthority = join(targetRuntime, authorityV3Directory);
|
|
322
|
+
for (let epoch = 0; epoch <= selectedSequence; epoch += 1)
|
|
323
|
+
await cloneImmutableTree(this.#epochRoot(epoch), join(targetAuthority, "epochs", epochName(epoch)));
|
|
324
|
+
await publishJson(join(targetRuntime, continuityHeadFile), selected);
|
|
325
|
+
return selected;
|
|
326
|
+
}
|
|
327
|
+
async #headAtGenesis(current, operationId) {
|
|
328
|
+
const genesis = await this.#readObject({ epoch: 0, digest: current.genesisDigest }, "authority.json", "file_native_authority_genesis");
|
|
329
|
+
assertGenesis(genesis);
|
|
330
|
+
await this.#assertAcceptedGenesis(current);
|
|
331
|
+
return {
|
|
332
|
+
schemaVersion: 3,
|
|
333
|
+
type: "file_native_continuity_head",
|
|
334
|
+
head: "genesis",
|
|
335
|
+
sequence: 0,
|
|
336
|
+
genesisDigest: current.genesisDigest,
|
|
337
|
+
commitDigest: null,
|
|
338
|
+
result: genesis.result,
|
|
339
|
+
operationId,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
async #headAtCommit(current, sequence, operationId) {
|
|
343
|
+
const { value, digest } = await this.#readAuthorityCommit(sequence);
|
|
344
|
+
await this.#assertAcceptedAuthorityRef(current, sequence, value.head, digest);
|
|
345
|
+
return {
|
|
346
|
+
schemaVersion: 3,
|
|
347
|
+
type: "file_native_continuity_head",
|
|
348
|
+
head: value.head,
|
|
349
|
+
sequence,
|
|
350
|
+
genesisDigest: current.genesisDigest,
|
|
351
|
+
commitDigest: digest,
|
|
352
|
+
result: value.result,
|
|
353
|
+
operationId,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
async #resultRefAt(head) {
|
|
357
|
+
const current = await this.readHead();
|
|
358
|
+
if (head === current.head) {
|
|
359
|
+
if (current.sequence === 0) {
|
|
360
|
+
const genesis = await this.#readObject({ epoch: 0, digest: current.genesisDigest }, "authority.json", "file_native_authority_genesis");
|
|
361
|
+
assertGenesis(genesis);
|
|
362
|
+
if (!isDeepStrictEqual(genesis.result, current.result))
|
|
363
|
+
throw new FileNativeAuthorityV3Error("corrupt", "Continuity head result does not match Authority genesis");
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
const { value, digest } = await this.#readAuthorityCommit(current.sequence);
|
|
367
|
+
if (value.head !== current.head ||
|
|
368
|
+
digest !== current.commitDigest ||
|
|
369
|
+
!isDeepStrictEqual(value.result, current.result))
|
|
370
|
+
throw new FileNativeAuthorityV3Error("corrupt", "Continuity head result does not match its Authority fact");
|
|
371
|
+
}
|
|
372
|
+
return current.result;
|
|
373
|
+
}
|
|
374
|
+
if (head === "genesis") {
|
|
375
|
+
const genesis = await this.#readObject({ epoch: 0, digest: current.genesisDigest }, "authority.json", "file_native_authority_genesis");
|
|
376
|
+
assertGenesis(genesis);
|
|
377
|
+
await this.#assertAcceptedGenesis(current);
|
|
378
|
+
return genesis.result;
|
|
379
|
+
}
|
|
380
|
+
const sequence = parseHeadSequence(head);
|
|
381
|
+
if (sequence > current.sequence)
|
|
382
|
+
throw new FileNativeAuthorityV3Error("conflict", `Authority endpoint does not exist: ${head}`);
|
|
383
|
+
const { value, digest } = await this.#readAuthorityCommit(sequence);
|
|
384
|
+
if (value.head !== head)
|
|
385
|
+
throw new FileNativeAuthorityV3Error("corrupt", `Authority endpoint identity is invalid: ${head}`);
|
|
386
|
+
await this.#assertAcceptedAuthorityRef(current, sequence, head, digest);
|
|
387
|
+
return value.result;
|
|
388
|
+
}
|
|
389
|
+
async #authorityRefAt(head) {
|
|
390
|
+
const current = await this.readHead();
|
|
391
|
+
if (head === "genesis") {
|
|
392
|
+
await this.#assertAcceptedGenesis(current);
|
|
393
|
+
return { head, digest: current.genesisDigest };
|
|
394
|
+
}
|
|
395
|
+
const sequence = parseHeadSequence(head);
|
|
396
|
+
const { value, digest } = await this.#readAuthorityCommit(sequence);
|
|
397
|
+
if (value.head !== head || sequence > current.sequence)
|
|
398
|
+
throw new FileNativeAuthorityV3Error("conflict", `Authority endpoint does not exist: ${head}`);
|
|
399
|
+
await this.#assertAcceptedAuthorityRef(current, sequence, head, digest);
|
|
400
|
+
return { head, digest };
|
|
401
|
+
}
|
|
402
|
+
async #assertAcceptedAuthorityRef(current, sequence, head, digest) {
|
|
403
|
+
if (sequence > current.sequence)
|
|
404
|
+
throw new FileNativeAuthorityV3Error("conflict", `Authority endpoint does not exist: ${head}`);
|
|
405
|
+
if (sequence === current.sequence) {
|
|
406
|
+
if (current.head !== head || current.commitDigest !== digest)
|
|
407
|
+
throw new FileNativeAuthorityV3Error("corrupt", `Authority endpoint is not the accepted head: ${head}`);
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
const { value: child } = await this.#readAuthorityCommit(sequence + 1);
|
|
411
|
+
if (child.auditParent.head !== head || child.auditParent.digest !== digest)
|
|
412
|
+
throw new FileNativeAuthorityV3Error("corrupt", `Authority endpoint is not in the accepted audit chain: ${head}`);
|
|
413
|
+
}
|
|
414
|
+
async #assertAcceptedGenesis(current) {
|
|
415
|
+
if (current.sequence === 0)
|
|
416
|
+
return;
|
|
417
|
+
const { value: first } = await this.#readAuthorityCommit(1);
|
|
418
|
+
if (first.auditParent.head !== "genesis" ||
|
|
419
|
+
first.auditParent.digest !== current.genesisDigest)
|
|
420
|
+
throw new FileNativeAuthorityV3Error("corrupt", "Authority audit chain does not extend its genesis anchor");
|
|
421
|
+
}
|
|
422
|
+
async #nextState(epoch, basisRef, changes) {
|
|
423
|
+
if (changes.length === 0)
|
|
424
|
+
return basisRef;
|
|
425
|
+
const basis = await this.#readStateManifest(basisRef);
|
|
426
|
+
const files = new Map(basis.files.map((file) => [file.path, file]));
|
|
427
|
+
for (const change of changes) {
|
|
428
|
+
const previous = files.get(change.relativePath);
|
|
429
|
+
if ((previous?.sha256 ?? null) !== change.expectedPreviousHash)
|
|
430
|
+
throw new FileNativeAuthorityV3Error("conflict", `State-change previous hash conflicts: ${change.relativePath}`);
|
|
431
|
+
if (hashWithPrefix(change.canonicalNextBytes) !== change.nextHash)
|
|
432
|
+
throw new FileNativeAuthorityV3Error("conflict", `State-change next hash conflicts: ${change.relativePath}`);
|
|
433
|
+
const blob = await this.#writeBlob(epoch, change.canonicalNextBytes);
|
|
434
|
+
files.set(change.relativePath, {
|
|
435
|
+
path: change.relativePath,
|
|
436
|
+
sha256: change.nextHash,
|
|
437
|
+
blob,
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
const manifest = {
|
|
441
|
+
schemaVersion: 3,
|
|
442
|
+
type: "file_native_state_manifest",
|
|
443
|
+
files: [...files.values()].sort((left, right) => left.path.localeCompare(right.path)),
|
|
444
|
+
};
|
|
445
|
+
assertStateManifest(manifest);
|
|
446
|
+
return this.#writeObject(epoch, "state.json", manifest);
|
|
447
|
+
}
|
|
448
|
+
async #writeStateManifest(epoch, files) {
|
|
449
|
+
const entries = [];
|
|
450
|
+
for (const file of files) {
|
|
451
|
+
const blob = await this.#writeBlob(epoch, file.contents);
|
|
452
|
+
entries.push({
|
|
453
|
+
path: file.path,
|
|
454
|
+
sha256: hashWithPrefix(file.contents),
|
|
455
|
+
blob,
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
const manifest = {
|
|
459
|
+
schemaVersion: 3,
|
|
460
|
+
type: "file_native_state_manifest",
|
|
461
|
+
files: entries.sort((left, right) => left.path.localeCompare(right.path)),
|
|
462
|
+
};
|
|
463
|
+
assertStateManifest(manifest);
|
|
464
|
+
return this.#writeObject(epoch, "state.json", manifest);
|
|
465
|
+
}
|
|
466
|
+
async #writeHistorySegment(epoch, segment) {
|
|
467
|
+
assertHistorySegment(segment);
|
|
468
|
+
return this.#writeObject(epoch, "history.json", segment);
|
|
469
|
+
}
|
|
470
|
+
async #writeBlob(epoch, contents) {
|
|
471
|
+
const digest = hashHex(contents);
|
|
472
|
+
const path = join(this.#epochRoot(epoch), "blobs", `${digest}.txt`);
|
|
473
|
+
await publishImmutableText(path, contents);
|
|
474
|
+
return { epoch, digest, sha256: `sha256:${digest}` };
|
|
475
|
+
}
|
|
476
|
+
async #recoverState(ref) {
|
|
477
|
+
const manifest = await this.#readStateManifest(ref);
|
|
478
|
+
const state = [];
|
|
479
|
+
for (const file of manifest.files) {
|
|
480
|
+
const contents = await readFile(join(this.#epochRoot(file.blob.epoch), "blobs", `${file.blob.digest}.txt`), "utf8");
|
|
481
|
+
if (hashHex(contents) !== file.blob.digest ||
|
|
482
|
+
hashWithPrefix(contents) !== file.sha256 ||
|
|
483
|
+
file.blob.sha256 !== file.sha256)
|
|
484
|
+
throw new FileNativeAuthorityV3Error("corrupt", `State blob digest does not match: ${file.path}`);
|
|
485
|
+
state.push({ path: file.path, contents });
|
|
486
|
+
}
|
|
487
|
+
return state;
|
|
488
|
+
}
|
|
489
|
+
async #recoverHistory(tail, expectedLength) {
|
|
490
|
+
const segments = [];
|
|
491
|
+
const seen = new Set();
|
|
492
|
+
let current = tail;
|
|
493
|
+
while (current !== null) {
|
|
494
|
+
const key = `${current.epoch}:${current.digest}`;
|
|
495
|
+
if (seen.has(key))
|
|
496
|
+
throw new FileNativeAuthorityV3Error("corrupt", "History segment chain contains a cycle");
|
|
497
|
+
seen.add(key);
|
|
498
|
+
const segment = await this.#readObject(current, "history.json", "file_native_history_segment");
|
|
499
|
+
assertHistorySegment(segment);
|
|
500
|
+
segments.push(segment);
|
|
501
|
+
current = segment.previous;
|
|
502
|
+
}
|
|
503
|
+
const history = segments
|
|
504
|
+
.reverse()
|
|
505
|
+
.flatMap(({ messages }) => structuredClone(messages));
|
|
506
|
+
if (history.length !== expectedLength)
|
|
507
|
+
throw new FileNativeAuthorityV3Error("corrupt", "History endpoint length does not match its immutable segment chain");
|
|
508
|
+
return history;
|
|
509
|
+
}
|
|
510
|
+
async #readEndpoint(ref) {
|
|
511
|
+
const value = await this.#readObject(ref, "endpoint.json", "file_native_authority_endpoint");
|
|
512
|
+
assertEndpoint(value);
|
|
513
|
+
return value;
|
|
514
|
+
}
|
|
515
|
+
async #readStateManifest(ref) {
|
|
516
|
+
const value = await this.#readObject(ref, "state.json", "file_native_state_manifest");
|
|
517
|
+
assertStateManifest(value);
|
|
518
|
+
return value;
|
|
519
|
+
}
|
|
520
|
+
async #readMaterials(ref) {
|
|
521
|
+
const value = await this.#readObject(ref, "materials.json", "file_native_material_set");
|
|
522
|
+
assertMaterialSet(value);
|
|
523
|
+
return value;
|
|
524
|
+
}
|
|
525
|
+
async #readAuthorityCommit(sequence) {
|
|
526
|
+
forbidAuthorityFactDecodeForTest();
|
|
527
|
+
const path = join(this.#epochRoot(sequence), "authority.json");
|
|
528
|
+
const value = await readJson(path);
|
|
529
|
+
if (!isRecord(value) ||
|
|
530
|
+
value.schemaVersion !== 3 ||
|
|
531
|
+
value.type !== "file_native_authority_commit")
|
|
532
|
+
throw new FileNativeAuthorityV3Error("corrupt", `Authority fact ${sequence} has an invalid shape`);
|
|
533
|
+
assertCommit(value);
|
|
534
|
+
const commit = value;
|
|
535
|
+
return { value: commit, digest: objectDigest(commit) };
|
|
536
|
+
}
|
|
537
|
+
async #writeObject(epoch, name, value) {
|
|
538
|
+
const digest = objectDigest(value);
|
|
539
|
+
await publishImmutableJson(join(this.#epochRoot(epoch), name), value);
|
|
540
|
+
return { epoch, digest };
|
|
541
|
+
}
|
|
542
|
+
async #readObject(ref, name, type) {
|
|
543
|
+
if (name === "authority.json")
|
|
544
|
+
forbidAuthorityFactDecodeForTest();
|
|
545
|
+
const value = await readJson(join(this.#epochRoot(ref.epoch), name));
|
|
546
|
+
if (!isRecord(value) ||
|
|
547
|
+
value.schemaVersion !== 3 ||
|
|
548
|
+
value.type !== type ||
|
|
549
|
+
objectDigest(value) !== ref.digest)
|
|
550
|
+
throw new FileNativeAuthorityV3Error("corrupt", `Immutable ${type} object does not match its reference`);
|
|
551
|
+
return value;
|
|
552
|
+
}
|
|
553
|
+
#epochRoot(epoch) {
|
|
554
|
+
return join(this.#authorityRoot, "epochs", epochName(epoch));
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
function assertGenesis(value) {
|
|
558
|
+
if (!isRecord(value) ||
|
|
559
|
+
!hasExactKeys(value, ["schemaVersion", "type", "result"]) ||
|
|
560
|
+
value.schemaVersion !== 3 ||
|
|
561
|
+
value.type !== "file_native_authority_genesis" ||
|
|
562
|
+
!isObjectRef(value.result) ||
|
|
563
|
+
value.result.epoch !== 0)
|
|
564
|
+
throw corruptShape("Authority genesis");
|
|
565
|
+
}
|
|
566
|
+
function assertEndpoint(value) {
|
|
567
|
+
if (!isRecord(value) ||
|
|
568
|
+
!hasExactKeys(value, [
|
|
569
|
+
"schemaVersion",
|
|
570
|
+
"type",
|
|
571
|
+
"state",
|
|
572
|
+
"history",
|
|
573
|
+
"historyLength",
|
|
574
|
+
"materials",
|
|
575
|
+
]) ||
|
|
576
|
+
value.schemaVersion !== 3 ||
|
|
577
|
+
value.type !== "file_native_authority_endpoint" ||
|
|
578
|
+
!isObjectRef(value.state) ||
|
|
579
|
+
(value.history !== null && !isObjectRef(value.history)) ||
|
|
580
|
+
!Number.isSafeInteger(value.historyLength) ||
|
|
581
|
+
Number(value.historyLength) < 0 ||
|
|
582
|
+
!isObjectRef(value.materials))
|
|
583
|
+
throw corruptShape("Authority endpoint");
|
|
584
|
+
}
|
|
585
|
+
function assertStateManifest(value) {
|
|
586
|
+
if (!isRecord(value) ||
|
|
587
|
+
!hasExactKeys(value, ["schemaVersion", "type", "files"]) ||
|
|
588
|
+
value.schemaVersion !== 3 ||
|
|
589
|
+
value.type !== "file_native_state_manifest" ||
|
|
590
|
+
!Array.isArray(value.files))
|
|
591
|
+
throw corruptShape("Authority state manifest");
|
|
592
|
+
const paths = new Set();
|
|
593
|
+
let previousPath = null;
|
|
594
|
+
for (const file of value.files) {
|
|
595
|
+
if (!isRecord(file) ||
|
|
596
|
+
!hasExactKeys(file, ["path", "sha256", "blob"]) ||
|
|
597
|
+
typeof file.path !== "string" ||
|
|
598
|
+
!validRelativePath(file.path) ||
|
|
599
|
+
paths.has(file.path) ||
|
|
600
|
+
(previousPath !== null && previousPath.localeCompare(file.path) >= 0) ||
|
|
601
|
+
typeof file.sha256 !== "string" ||
|
|
602
|
+
!isSha256(file.sha256) ||
|
|
603
|
+
!isBlobRef(file.blob) ||
|
|
604
|
+
file.blob.sha256 !== file.sha256)
|
|
605
|
+
throw corruptShape("Authority state manifest entry");
|
|
606
|
+
paths.add(file.path);
|
|
607
|
+
previousPath = file.path;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
function assertHistorySegment(value) {
|
|
611
|
+
if (!isRecord(value) ||
|
|
612
|
+
!hasExactKeys(value, ["schemaVersion", "type", "previous", "messages"]) ||
|
|
613
|
+
value.schemaVersion !== 3 ||
|
|
614
|
+
value.type !== "file_native_history_segment" ||
|
|
615
|
+
(value.previous !== null && !isObjectRef(value.previous)) ||
|
|
616
|
+
!Array.isArray(value.messages) ||
|
|
617
|
+
!value.messages.every(isHistoryMessage))
|
|
618
|
+
throw corruptShape("Authority history segment");
|
|
619
|
+
}
|
|
620
|
+
function assertGenesisHistory(messages) {
|
|
621
|
+
for (const [index, message] of messages.entries()) {
|
|
622
|
+
const expected = index === 0 && message.role === "narrator"
|
|
623
|
+
? "message.genesis.narrator"
|
|
624
|
+
: `message.genesis.${index + 1}.${message.role}`;
|
|
625
|
+
if (message.messageId !== expected)
|
|
626
|
+
throw corruptShape("Authority genesis history-message identity");
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
function assertMaterialSet(value) {
|
|
630
|
+
if (!isRecord(value) ||
|
|
631
|
+
!hasExactKeys(value, ["schemaVersion", "type", "items"]) ||
|
|
632
|
+
value.schemaVersion !== 3 ||
|
|
633
|
+
value.type !== "file_native_material_set" ||
|
|
634
|
+
!Array.isArray(value.items) ||
|
|
635
|
+
!value.items.every(isMaterialSelection))
|
|
636
|
+
throw corruptShape("Authority material set");
|
|
637
|
+
}
|
|
638
|
+
function assertCommit(value) {
|
|
639
|
+
if (!isRecord(value))
|
|
640
|
+
throw corruptShape("Authority commit");
|
|
641
|
+
const correction = value.mode === "correction";
|
|
642
|
+
const revision = value.mode === "timeline_revision";
|
|
643
|
+
if (!hasExactKeys(value, [
|
|
644
|
+
"schemaVersion",
|
|
645
|
+
"type",
|
|
646
|
+
"sequence",
|
|
647
|
+
"head",
|
|
648
|
+
"auditParent",
|
|
649
|
+
"timelineParent",
|
|
650
|
+
"mode",
|
|
651
|
+
"historyAppend",
|
|
652
|
+
"stateChanges",
|
|
653
|
+
"nextAdditionalMaterials",
|
|
654
|
+
"result",
|
|
655
|
+
...(correction ? ["correctionTargets", "corrects"] : []),
|
|
656
|
+
...(revision ? ["timelineRevision"] : []),
|
|
657
|
+
]) ||
|
|
658
|
+
value.schemaVersion !== 3 ||
|
|
659
|
+
value.type !== "file_native_authority_commit" ||
|
|
660
|
+
!Number.isSafeInteger(value.sequence) ||
|
|
661
|
+
Number(value.sequence) < 1 ||
|
|
662
|
+
value.head !== `commit:${String(value.sequence)}` ||
|
|
663
|
+
!isAuthorityParent(value.auditParent) ||
|
|
664
|
+
!isAuthorityParent(value.timelineParent) ||
|
|
665
|
+
(value.mode !== "play" && !correction && !revision) ||
|
|
666
|
+
!Array.isArray(value.historyAppend) ||
|
|
667
|
+
!value.historyAppend.every(isHistoryMessage) ||
|
|
668
|
+
!Array.isArray(value.stateChanges) ||
|
|
669
|
+
!value.stateChanges.every(isStoredStateChange) ||
|
|
670
|
+
!Array.isArray(value.nextAdditionalMaterials) ||
|
|
671
|
+
!value.nextAdditionalMaterials.every(isMaterialSelection) ||
|
|
672
|
+
!isObjectRef(value.result) ||
|
|
673
|
+
value.result.epoch !== value.sequence)
|
|
674
|
+
throw corruptShape("Authority commit");
|
|
675
|
+
for (const [index, message] of value.historyAppend.entries())
|
|
676
|
+
if (message.messageId !==
|
|
677
|
+
`message.${value.sequence}.${index + 1}.${message.role}`)
|
|
678
|
+
throw corruptShape("Authority history-message identity");
|
|
679
|
+
if (value.stateChanges.some((change) => change.nextBlob.epoch !== value.sequence ||
|
|
680
|
+
`sha256:${change.nextBlob.digest}` !== change.nextHash))
|
|
681
|
+
throw corruptShape("Authority state-change blob");
|
|
682
|
+
if (correction &&
|
|
683
|
+
(!Array.isArray(value.correctionTargets) ||
|
|
684
|
+
!value.correctionTargets.every((target) => typeof target === "string" && target.trim() !== "") ||
|
|
685
|
+
typeof value.corrects !== "string" ||
|
|
686
|
+
value.corrects !== value.auditParent.head ||
|
|
687
|
+
value.historyAppend.length !== 0))
|
|
688
|
+
throw corruptShape("Authority correction");
|
|
689
|
+
if (revision &&
|
|
690
|
+
(value.stateChanges.length !== 0 ||
|
|
691
|
+
value.historyAppend.length !== 1 ||
|
|
692
|
+
value.historyAppend[0]?.role !== "player" ||
|
|
693
|
+
!isTimelineRevision(value.timelineRevision) ||
|
|
694
|
+
value.timelineParent.head !== value.timelineRevision.restoresHead))
|
|
695
|
+
throw corruptShape("Authority timeline revision");
|
|
696
|
+
if (value.mode === "play" &&
|
|
697
|
+
value.historyAppend.length === 0 &&
|
|
698
|
+
value.stateChanges.length === 0)
|
|
699
|
+
throw corruptShape("Authority play commit");
|
|
700
|
+
}
|
|
701
|
+
function assertHead(value) {
|
|
702
|
+
if (!isRecord(value) ||
|
|
703
|
+
!hasExactKeys(value, [
|
|
704
|
+
"schemaVersion",
|
|
705
|
+
"type",
|
|
706
|
+
"head",
|
|
707
|
+
"sequence",
|
|
708
|
+
"genesisDigest",
|
|
709
|
+
"commitDigest",
|
|
710
|
+
"result",
|
|
711
|
+
"operationId",
|
|
712
|
+
]) ||
|
|
713
|
+
value.schemaVersion !== 3 ||
|
|
714
|
+
value.type !== "file_native_continuity_head" ||
|
|
715
|
+
typeof value.head !== "string" ||
|
|
716
|
+
!Number.isSafeInteger(value.sequence) ||
|
|
717
|
+
value.sequence < 0 ||
|
|
718
|
+
typeof value.genesisDigest !== "string" ||
|
|
719
|
+
!isDigest(value.genesisDigest) ||
|
|
720
|
+
(value.commitDigest !== null &&
|
|
721
|
+
(typeof value.commitDigest !== "string" ||
|
|
722
|
+
!isDigest(value.commitDigest))) ||
|
|
723
|
+
!isObjectRef(value.result) ||
|
|
724
|
+
value.result.epoch !== value.sequence ||
|
|
725
|
+
(value.operationId !== null && typeof value.operationId !== "string") ||
|
|
726
|
+
(value.sequence === 0
|
|
727
|
+
? value.head !== "genesis" || value.commitDigest !== null
|
|
728
|
+
: value.head !== `commit:${String(value.sequence)}` ||
|
|
729
|
+
value.commitDigest === null))
|
|
730
|
+
throw new FileNativeAuthorityV3Error("corrupt", "Continuity head has an invalid shape");
|
|
731
|
+
}
|
|
732
|
+
function isHistoryMessage(value) {
|
|
733
|
+
if (!isRecord(value) ||
|
|
734
|
+
!hasExactKeys(value, ["messageId", "role", "exactText"]) ||
|
|
735
|
+
typeof value.messageId !== "string" ||
|
|
736
|
+
(value.role !== "player" && value.role !== "narrator") ||
|
|
737
|
+
typeof value.exactText !== "string")
|
|
738
|
+
return false;
|
|
739
|
+
const match = /^message\.(?:genesis(?:\.([1-9][0-9]*))?|([1-9][0-9]*)\.([1-9][0-9]*))\.(player|narrator)$/u.exec(value.messageId);
|
|
740
|
+
return match !== null && match[4] === value.role;
|
|
741
|
+
}
|
|
742
|
+
function isStoredStateChange(value) {
|
|
743
|
+
return (isRecord(value) &&
|
|
744
|
+
hasExactKeys(value, [
|
|
745
|
+
"kind",
|
|
746
|
+
"documentId",
|
|
747
|
+
"stableShortRef",
|
|
748
|
+
"relativePath",
|
|
749
|
+
"codec",
|
|
750
|
+
"expectedPreviousHash",
|
|
751
|
+
"nextHash",
|
|
752
|
+
"nextBlob",
|
|
753
|
+
]) &&
|
|
754
|
+
(value.kind === "create" || value.kind === "replace") &&
|
|
755
|
+
typeof value.documentId === "string" &&
|
|
756
|
+
value.documentId.trim() !== "" &&
|
|
757
|
+
typeof value.stableShortRef === "string" &&
|
|
758
|
+
value.stableShortRef.trim() !== "" &&
|
|
759
|
+
typeof value.relativePath === "string" &&
|
|
760
|
+
validRelativePath(value.relativePath) &&
|
|
761
|
+
(value.codec === "yaml" || value.codec === "markdown") &&
|
|
762
|
+
(value.expectedPreviousHash === null ||
|
|
763
|
+
(typeof value.expectedPreviousHash === "string" &&
|
|
764
|
+
isSha256(value.expectedPreviousHash))) &&
|
|
765
|
+
typeof value.nextHash === "string" &&
|
|
766
|
+
isSha256(value.nextHash) &&
|
|
767
|
+
isBlobRef(value.nextBlob));
|
|
768
|
+
}
|
|
769
|
+
function isTimelineRevision(value) {
|
|
770
|
+
return (isRecord(value) &&
|
|
771
|
+
hasExactKeys(value, [
|
|
772
|
+
"restoresHead",
|
|
773
|
+
"replacesHead",
|
|
774
|
+
"requestFingerprint",
|
|
775
|
+
]) &&
|
|
776
|
+
typeof value.restoresHead === "string" &&
|
|
777
|
+
isHeadAlias(value.restoresHead) &&
|
|
778
|
+
typeof value.replacesHead === "string" &&
|
|
779
|
+
/^commit:[1-9][0-9]*$/u.test(value.replacesHead) &&
|
|
780
|
+
typeof value.requestFingerprint === "string" &&
|
|
781
|
+
isSha256(value.requestFingerprint));
|
|
782
|
+
}
|
|
783
|
+
function isAuthorityParent(value) {
|
|
784
|
+
return (isRecord(value) &&
|
|
785
|
+
hasExactKeys(value, ["head", "digest"]) &&
|
|
786
|
+
typeof value.head === "string" &&
|
|
787
|
+
isHeadAlias(value.head) &&
|
|
788
|
+
typeof value.digest === "string" &&
|
|
789
|
+
isDigest(value.digest));
|
|
790
|
+
}
|
|
791
|
+
function isMaterialSelection(value) {
|
|
792
|
+
if (!isRecord(value))
|
|
793
|
+
return false;
|
|
794
|
+
if (value.kind === "document")
|
|
795
|
+
return (hasExactKeys(value, ["kind", "document"]) &&
|
|
796
|
+
typeof value.document === "string");
|
|
797
|
+
if (value.kind === "node")
|
|
798
|
+
return (hasExactKeys(value, ["kind", "document", "locator"]) &&
|
|
799
|
+
typeof value.document === "string" &&
|
|
800
|
+
isMaterialLocator(value.locator));
|
|
801
|
+
if (value.kind === "history_message")
|
|
802
|
+
return (hasExactKeys(value, ["kind", "message"]) &&
|
|
803
|
+
typeof value.message === "string" &&
|
|
804
|
+
/^message\.(?:genesis(?:\.[1-9][0-9]*)?\.(?:player|narrator)|[1-9][0-9]*\.[1-9][0-9]*\.(?:player|narrator))$/u.test(value.message));
|
|
805
|
+
return (value.kind === "history_commit" &&
|
|
806
|
+
hasExactKeys(value, ["kind", "commit"]) &&
|
|
807
|
+
typeof value.commit === "string" &&
|
|
808
|
+
isHeadAlias(value.commit));
|
|
809
|
+
}
|
|
810
|
+
function isMaterialLocator(value) {
|
|
811
|
+
if (!isRecord(value))
|
|
812
|
+
return false;
|
|
813
|
+
const keys = Object.keys(value);
|
|
814
|
+
return (keys.every((key) => key === "yaml" || key === "markdown") &&
|
|
815
|
+
(value.yaml === undefined ||
|
|
816
|
+
(Array.isArray(value.yaml) &&
|
|
817
|
+
value.yaml.every((segment) => typeof segment === "string" || Number.isSafeInteger(segment)))) &&
|
|
818
|
+
(value.markdown === undefined ||
|
|
819
|
+
(Array.isArray(value.markdown) &&
|
|
820
|
+
value.markdown.every((segment) => typeof segment === "string"))));
|
|
821
|
+
}
|
|
822
|
+
function isBlobRef(value) {
|
|
823
|
+
return (isRecord(value) &&
|
|
824
|
+
hasExactKeys(value, ["epoch", "digest", "sha256"]) &&
|
|
825
|
+
Number.isSafeInteger(value.epoch) &&
|
|
826
|
+
Number(value.epoch) >= 0 &&
|
|
827
|
+
typeof value.digest === "string" &&
|
|
828
|
+
isDigest(value.digest) &&
|
|
829
|
+
typeof value.sha256 === "string" &&
|
|
830
|
+
isSha256(value.sha256) &&
|
|
831
|
+
value.sha256 === `sha256:${value.digest}`);
|
|
832
|
+
}
|
|
833
|
+
function isHeadAlias(value) {
|
|
834
|
+
return /^(?:genesis|commit:[1-9][0-9]*)$/u.test(value);
|
|
835
|
+
}
|
|
836
|
+
function isSha256(value) {
|
|
837
|
+
return /^sha256:[a-f0-9]{64}$/u.test(value);
|
|
838
|
+
}
|
|
839
|
+
function validRelativePath(path) {
|
|
840
|
+
return (path.length > 0 &&
|
|
841
|
+
!path.startsWith("/") &&
|
|
842
|
+
!path.includes("\\") &&
|
|
843
|
+
!path
|
|
844
|
+
.split("/")
|
|
845
|
+
.some((part) => part === "" || part === "." || part === ".."));
|
|
846
|
+
}
|
|
847
|
+
function hasExactKeys(value, keys) {
|
|
848
|
+
return (Object.keys(value).length === keys.length &&
|
|
849
|
+
keys.every((key) => Object.hasOwn(value, key)));
|
|
850
|
+
}
|
|
851
|
+
function corruptShape(label) {
|
|
852
|
+
return new FileNativeAuthorityV3Error("corrupt", `${label} has an invalid shape`);
|
|
853
|
+
}
|
|
854
|
+
function isObjectRef(value) {
|
|
855
|
+
return (isRecord(value) &&
|
|
856
|
+
hasExactKeys(value, ["epoch", "digest"]) &&
|
|
857
|
+
Number.isSafeInteger(value.epoch) &&
|
|
858
|
+
value.epoch >= 0 &&
|
|
859
|
+
typeof value.digest === "string" &&
|
|
860
|
+
isDigest(value.digest));
|
|
861
|
+
}
|
|
862
|
+
function parseHeadSequence(head) {
|
|
863
|
+
const match = /^commit:([1-9][0-9]*)$/u.exec(head);
|
|
864
|
+
const sequence = Number(match?.[1]);
|
|
865
|
+
if (!Number.isSafeInteger(sequence) || sequence < 1)
|
|
866
|
+
throw new FileNativeAuthorityV3Error("conflict", `Authority endpoint identity is invalid: ${head}`);
|
|
867
|
+
return sequence;
|
|
868
|
+
}
|
|
869
|
+
function epochName(epoch) {
|
|
870
|
+
return String(epoch).padStart(8, "0");
|
|
871
|
+
}
|
|
872
|
+
function objectDigest(value) {
|
|
873
|
+
return hashHex(JSON.stringify(value));
|
|
874
|
+
}
|
|
875
|
+
function hashHex(value) {
|
|
876
|
+
return createHash("sha256").update(value).digest("hex");
|
|
877
|
+
}
|
|
878
|
+
function hashWithPrefix(value) {
|
|
879
|
+
return `sha256:${hashHex(value)}`;
|
|
880
|
+
}
|
|
881
|
+
function isDigest(value) {
|
|
882
|
+
return /^[a-f0-9]{64}$/u.test(value);
|
|
883
|
+
}
|
|
884
|
+
async function cloneImmutableTree(source, target) {
|
|
885
|
+
await mkdir(target, { recursive: true, mode: 0o700 });
|
|
886
|
+
const entries = await readdir(source, { withFileTypes: true });
|
|
887
|
+
for (const entry of entries) {
|
|
888
|
+
const sourcePath = join(source, entry.name);
|
|
889
|
+
const targetPath = join(target, entry.name);
|
|
890
|
+
if (entry.isDirectory()) {
|
|
891
|
+
await cloneImmutableTree(sourcePath, targetPath);
|
|
892
|
+
continue;
|
|
893
|
+
}
|
|
894
|
+
if (!entry.isFile())
|
|
895
|
+
throw new FileNativeAuthorityV3Error("corrupt", `Immutable Authority closure contains a non-file entry: ${entry.name}`);
|
|
896
|
+
await cloneImmutableFile(sourcePath, targetPath);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
async function cloneImmutableFile(source, target) {
|
|
900
|
+
await cloneFilePhysically({ source, target, immutable: true });
|
|
901
|
+
}
|
|
902
|
+
async function publishImmutableJson(path, value) {
|
|
903
|
+
await publishImmutableText(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
904
|
+
}
|
|
905
|
+
async function publishImmutableText(path, contents) {
|
|
906
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
907
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
908
|
+
try {
|
|
909
|
+
await writeFile(temporary, contents, {
|
|
910
|
+
encoding: "utf8",
|
|
911
|
+
flag: "wx",
|
|
912
|
+
mode: 0o600,
|
|
913
|
+
});
|
|
914
|
+
await syncFile(temporary);
|
|
915
|
+
try {
|
|
916
|
+
await link(temporary, path);
|
|
917
|
+
await syncDirectory(dirname(path));
|
|
918
|
+
}
|
|
919
|
+
catch (error) {
|
|
920
|
+
if (!isNodeError(error) || error.code !== "EEXIST")
|
|
921
|
+
throw error;
|
|
922
|
+
if ((await readFile(path, "utf8")) !== contents)
|
|
923
|
+
throw new FileNativeAuthorityV3Error("corrupt", `Immutable Authority object conflicts: ${path}`);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
finally {
|
|
927
|
+
await rm(temporary, { force: true });
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
async function publishJson(path, value) {
|
|
931
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
932
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
933
|
+
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, {
|
|
934
|
+
encoding: "utf8",
|
|
935
|
+
mode: 0o600,
|
|
936
|
+
});
|
|
937
|
+
await syncFile(temporary);
|
|
938
|
+
await rename(temporary, path);
|
|
939
|
+
await syncDirectory(dirname(path));
|
|
940
|
+
}
|
|
941
|
+
async function syncFile(path) {
|
|
942
|
+
const handle = await open(path, "r");
|
|
943
|
+
try {
|
|
944
|
+
await handle.sync();
|
|
945
|
+
}
|
|
946
|
+
finally {
|
|
947
|
+
await handle.close();
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
async function syncDirectory(path) {
|
|
951
|
+
const handle = await open(path, "r");
|
|
952
|
+
try {
|
|
953
|
+
await handle.sync();
|
|
954
|
+
}
|
|
955
|
+
finally {
|
|
956
|
+
await handle.close();
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
async function readJson(path) {
|
|
960
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
961
|
+
}
|
|
962
|
+
function isRecord(value) {
|
|
963
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
964
|
+
}
|
|
965
|
+
function isNodeError(error) {
|
|
966
|
+
return error instanceof Error && "code" in error;
|
|
967
|
+
}
|
|
968
|
+
function forbidAuthorityFactDecodeForTest() {
|
|
969
|
+
if (process.env.NARRAEON_INTERNAL_TEST_FORBID_AUTHORITY_FACT_DECODE === "1")
|
|
970
|
+
throw new Error("Authority fact decoding is forbidden by this test");
|
|
971
|
+
}
|