@zktx.io/sui-mcp 0.5.0 → 0.6.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/README.md +21 -21
- package/dist/adapters/adapterPromptSurfaces.js +0 -20
- package/dist/adapters/deepbook/deepbookQuotePolicy.js +0 -4
- package/dist/adapters/deepbook/deepbookReviewEvidence.js +0 -9
- package/dist/adapters/deepbook/deepbookTransactionMaterialProducer.js +0 -9
- package/dist/adapters/intentPlanFactories.js +0 -6
- package/dist/core/action/contractNameRegistry.js +0 -53
- package/dist/core/action/forbiddenFields.js +0 -4
- package/dist/core/action/ptbVisualizationProducer.js +0 -3
- package/dist/core/action/receiptPtbGraph.js +0 -17
- package/dist/core/action/reviewTimeSimulationEvidence.js +0 -4
- package/dist/core/action/signableAdapterContract.js +0 -5
- package/dist/core/action/suiChainReceiptMapping.js +0 -18
- package/dist/core/action/suiChainReceiptReader.js +1 -21
- package/dist/core/action/suiChainReceiptVerifier.js +0 -10
- package/dist/core/action/transactionFunding.js +0 -7
- package/dist/core/action/types.js +0 -1
- package/dist/core/action/walletReviewContractAssembler.js +0 -2
- package/dist/core/activity/localDataService.js +65 -52
- package/dist/core/activity/localDataValidation.js +1 -3
- package/dist/core/activity/sqliteActivityStore.js +24 -24
- package/dist/core/activity/sqliteActivityStoreRows.js +0 -2
- package/dist/core/activity/sqliteActivityStoreSchema.js +81 -14
- package/dist/core/preferences/sqlitePreferencesRepository.js +5 -3
- package/dist/core/read/deepbookReadHelpers.js +0 -2
- package/dist/core/read/deepbookRegistry.js +0 -19
- package/dist/core/read/deepbookUsdcChartService.js +0 -2
- package/dist/core/read/readCardResult.js +0 -2
- package/dist/core/read/readService.js +0 -19
- package/dist/core/read/readServiceTypes.js +0 -2
- package/dist/core/session/cardSession.js +1 -5
- package/dist/core/session/cardSessionStore.js +54 -7
- package/dist/core/session/reviewValidity.js +0 -3
- package/dist/core/session/sessionRecordStore.js +0 -1
- package/dist/core/session/sessionStore.js +0 -32
- package/dist/core/session/settingsFileIdentity.js +9 -0
- package/dist/core/session/settingsFileProtocol.js +20 -0
- package/dist/core/session/settingsFileStore.js +337 -0
- package/dist/core/session/settingsInputError.js +32 -0
- package/dist/core/session/settingsOperation.js +220 -0
- package/dist/core/session/settingsPreparationSql.js +13 -0
- package/dist/core/session/settingsWorkflow.js +1183 -0
- package/dist/core/session/sqliteCardStore.js +0 -2
- package/dist/core/session/sqliteSessionStore.js +0 -51
- package/dist/core/session/sqliteSettingsWorkflowStore.js +453 -0
- package/dist/core/session/sqliteTransactionMaterialStore.js +0 -11
- package/dist/core/session/sqliteWalletWorkflowStore.js +160 -66
- package/dist/core/session/status.js +2 -4
- package/dist/core/session/transactionMaterialStore.js +0 -4
- package/dist/core/session/transactionRequest.js +0 -4
- package/dist/core/session/walletConnection.js +26 -10
- package/dist/core/session/walletWorkflow.js +70 -30
- package/dist/core/session/workflowState.js +14 -14
- package/dist/core/session/workflowView.js +18 -8
- package/dist/core/suiAddress.js +0 -5
- package/dist/core/suiEndpoint.js +0 -3
- package/dist/mcp/prompts.js +0 -12
- package/dist/mcp/responseGuidance.js +10 -5
- package/dist/mcp/schemas.js +0 -3
- package/dist/mcp/serverInfo.js +3 -4
- package/dist/mcp/toolNames.js +2 -2
- package/dist/mcp/tools/account/index.js +1 -19
- package/dist/mcp/tools/action/prepareSuiActionReview.js +0 -6
- package/dist/mcp/tools/read/readToolHelpers.js +5 -4
- package/dist/mcp/tools/read/serverStatusTools.js +0 -1
- package/dist/mcp/tools/session/executionResultTools.js +2 -2
- package/dist/mcp/tools/session/shared.js +1 -2
- package/dist/mcp/tools/session/statusTools.js +4 -5
- package/dist/mcp/tools/settings/index.js +8 -28
- package/dist/mcp-app/account.html +14 -14
- package/dist/mcp-app/account.notices.txt +1 -1
- package/dist/mcp-app/chart.html +9 -9
- package/dist/mcp-app/chart.notices.txt +1 -1
- package/dist/mcp-app/connect.html +14 -14
- package/dist/mcp-app/connect.notices.txt +1 -1
- package/dist/mcp-app/receipt.html +1298 -1463
- package/dist/mcp-app/receipt.notices.txt +226 -1204
- package/dist/mcp-app/review.html +1298 -1463
- package/dist/mcp-app/review.notices.txt +226 -1204
- package/dist/mcp-app/settings.html +3133 -0
- package/dist/mcp-app/settings.notices.txt +973 -0
- package/dist/mcp-ui/contracts.js +8 -2
- package/dist/mcp-ui/readCards.js +1 -1
- package/dist/mcp-ui/tools.js +34 -4
- package/dist/review-server/server.js +3 -51
- package/dist/runtime/application.js +41 -14
- package/dist/runtime/config.js +0 -2
- package/dist/runtime/localSettingsService.js +23 -2
- package/dist/runtime/logger.js +0 -2
- package/dist/runtime/reviewServerAcquire.js +1 -18
- package/dist/runtime/runtimeTermination.js +1 -4
- package/dist/runtime/shared/authenticatedFetch.js +2 -4
- package/dist/runtime/shared/control.js +4 -7
- package/dist/runtime/shared/dataAccess.js +0 -2
- package/dist/runtime/shared/http.js +14 -0
- package/dist/runtime/shared/mcpHttp.js +6 -6
- package/dist/runtime/shared/ownerLease.js +0 -4
- package/dist/runtime/shared/server.js +1 -2
- package/dist/runtime/shared/stdio.js +2 -5
- package/dist/runtime/smokeMainnetRead.js +0 -3
- package/dist/runtime/smokeReview.js +0 -4
- package/dist/runtime/start.js +0 -2
- package/dist/runtime/suiTransactionGraphqlMapping.js +0 -2
- package/dist/runtime/suiTransactionGraphqlQueries.js +0 -3
- package/dist/runtime/walletConnectConfig.js +0 -2
- package/dist/runtime/walletConnectStorage.js +2 -10
- package/dist/runtime/walletConnectTransport.js +0 -11
- package/dist/runtime/walletSdkChildRuntime.js +2 -6
- package/dist/runtime/walletSdkIpc.js +2 -1
- package/dist/runtime/walletSdkProcess.js +3 -8
- package/docs/AGENT_BEHAVIOR.md +45 -15
- package/docs/LOCAL_DB_ARCHITECTURE.md +145 -22
- package/docs/MCP_SETUP.md +99 -39
- package/docs/MCP_TOOLS.md +288 -24
- package/docs/SDK_API.md +63 -10
- package/docs/UTILITY_INDEX.md +9 -6
- package/docs/WALLET_CONNECTION.md +32 -10
- package/package.json +22 -8
- package/dist/core/activity/schemaVersion.js +0 -2
- package/dist/core/session/keyedRecordStore.js +0 -18
- package/dist/core/session/settingsSession.js +0 -1
- package/dist/core/session/settingsSessions.js +0 -45
- package/dist/review-app/brand-dark.svg +0 -1
- package/dist/review-app/brand-light.svg +0 -1
- package/dist/review-app/favicon.svg +0 -1
- package/dist/review-app/settings.css +0 -1
- package/dist/review-app/settings.js +0 -1
- package/dist/review-app/ui.css +0 -809
- package/dist/review-server/assets.js +0 -48
- package/dist/review-server/html.js +0 -45
- package/dist/review-server/http.js +0 -47
- package/dist/review-server/middleware/reviewToken.js +0 -7
- package/dist/review-server/settingsApi.js +0 -158
- package/docs/AGENT_DEVELOPMENT_POLICY.md +0 -815
- package/docs/FRONTEND_POLICY.md +0 -534
- package/docs/golden-scenarios/BEHAVIOR_MATRIX.md +0 -166
- package/docs/golden-scenarios/INTENT_EVIDENCE_GOLDEN_ANSWERS.md +0 -161
- package/docs/golden-scenarios/INTENT_EVIDENCE_MATRIX.md +0 -259
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { lstat, mkdir, open, link, unlink } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { isSettingsDownloadFile, settingsBackupPath } from "./settingsFileIdentity.js";
|
|
6
|
+
import { SettingsConflict } from "./settingsOperation.js";
|
|
7
|
+
export class SettingsDownloadPathExists extends SettingsConflict {
|
|
8
|
+
}
|
|
9
|
+
function fileCode(error, code) {
|
|
10
|
+
return !!error && typeof error === "object" && "code" in error && error.code === code;
|
|
11
|
+
}
|
|
12
|
+
export function settingsFileDigest(data) { return createHash("sha256").update(data).digest("hex"); }
|
|
13
|
+
export function decodeSettingsChunk(value) {
|
|
14
|
+
if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) {
|
|
15
|
+
throw new SettingsConflict("Part of the file was not encoded correctly.");
|
|
16
|
+
}
|
|
17
|
+
const data = Buffer.from(value, "base64");
|
|
18
|
+
if (data.toString("base64") !== value)
|
|
19
|
+
throw new SettingsConflict("Part of the file was not encoded correctly.");
|
|
20
|
+
return data;
|
|
21
|
+
}
|
|
22
|
+
export class SettingsFileStore {
|
|
23
|
+
dataDirectory;
|
|
24
|
+
constructor(dataDirectory) {
|
|
25
|
+
this.dataDirectory = dataDirectory;
|
|
26
|
+
}
|
|
27
|
+
directory(role) {
|
|
28
|
+
return join(this.dataDirectory, role === "staging" ? ".settings-staging" : "backups");
|
|
29
|
+
}
|
|
30
|
+
assertId(id) {
|
|
31
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) {
|
|
32
|
+
throw new SettingsConflict("The selected file could not be identified.");
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
async prepareDirectory(role) {
|
|
36
|
+
const directory = this.directory(role);
|
|
37
|
+
await mkdir(directory, { mode: 0o700, recursive: true });
|
|
38
|
+
const info = await lstat(directory);
|
|
39
|
+
if (!info.isDirectory() || info.isSymbolicLink())
|
|
40
|
+
throw new SettingsConflict("The local file directory is unavailable.");
|
|
41
|
+
return directory;
|
|
42
|
+
}
|
|
43
|
+
stagingPath(file) {
|
|
44
|
+
this.assertId(file.fileId);
|
|
45
|
+
return join(this.directory("staging"), `${file.fileId}.${file.role === "upload" ? "upload" : isSettingsDownloadFile(file) ? "download" : "stage"}`);
|
|
46
|
+
}
|
|
47
|
+
finalPath(file) {
|
|
48
|
+
this.assertId(file.operationId);
|
|
49
|
+
if (file.role !== "export" || file.relativePath !== `backups/${file.operationId}.json`) {
|
|
50
|
+
throw new SettingsConflict("The backup location does not match this request.");
|
|
51
|
+
}
|
|
52
|
+
return join(this.directory("backups"), `${file.operationId}.json`);
|
|
53
|
+
}
|
|
54
|
+
exportPath(file) { return this.finalPath(file); }
|
|
55
|
+
localBackupPath(file) {
|
|
56
|
+
this.assertId(file.operationId);
|
|
57
|
+
if (!isSettingsDownloadFile(file))
|
|
58
|
+
throw new SettingsConflict("This request does not own a download snapshot.");
|
|
59
|
+
return join(this.dataDirectory, settingsBackupPath(file.operationId));
|
|
60
|
+
}
|
|
61
|
+
async verifiedIdentity(handle, path, file) {
|
|
62
|
+
const before = await handle.stat({ bigint: true });
|
|
63
|
+
if (!before.isFile() || before.size !== BigInt(file.totalSize))
|
|
64
|
+
throw new SettingsConflict("The backup file size changed.");
|
|
65
|
+
const hash = createHash("sha256");
|
|
66
|
+
for await (const chunk of handle.createReadStream({ start: 0, autoClose: false }))
|
|
67
|
+
hash.update(chunk);
|
|
68
|
+
const after = await handle.stat({ bigint: true }), atPath = await lstat(path, { bigint: true });
|
|
69
|
+
if (!atPath.isFile() || atPath.isSymbolicLink() || before.dev !== after.dev || before.ino !== after.ino ||
|
|
70
|
+
after.dev !== atPath.dev || after.ino !== atPath.ino || after.size !== before.size || atPath.size !== before.size ||
|
|
71
|
+
hash.digest("hex") !== file.sha256)
|
|
72
|
+
throw new SettingsConflict("The backup file changed while it was checked.");
|
|
73
|
+
return { device: before.dev.toString(), inode: before.ino.toString() };
|
|
74
|
+
}
|
|
75
|
+
sameFile(left, right) {
|
|
76
|
+
return left.device === right.device && left.inode === right.inode;
|
|
77
|
+
}
|
|
78
|
+
async downloadSourceIdentity(file) {
|
|
79
|
+
const directory = await lstat(this.directory("staging"));
|
|
80
|
+
if (!directory.isDirectory() || directory.isSymbolicLink())
|
|
81
|
+
throw new SettingsConflict("The backup staging directory changed.");
|
|
82
|
+
const path = this.stagingPath(file), handle = await this.handle(path, constants.O_RDONLY);
|
|
83
|
+
try {
|
|
84
|
+
return await this.verifiedIdentity(handle, path, file);
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
await handle.close();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
async publishDownload(file, rememberSource) {
|
|
91
|
+
if (!isSettingsDownloadFile(file) || !file.sealed || file.invalidated)
|
|
92
|
+
throw new SettingsConflict("The snapshot is not ready to save.");
|
|
93
|
+
await this.prepareDirectory("backups");
|
|
94
|
+
const source = this.stagingPath(file), destination = this.localBackupPath(file);
|
|
95
|
+
const identity = await this.downloadSourceIdentity(file);
|
|
96
|
+
rememberSource(identity);
|
|
97
|
+
try {
|
|
98
|
+
await link(source, destination);
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
if (fileCode(error, "EEXIST"))
|
|
102
|
+
return { status: "failed", reason: "target_conflict" };
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
return this.inspectDownloadPublication(file, identity);
|
|
106
|
+
}
|
|
107
|
+
async inspectDownloadPublication(file, sourceIdentity) {
|
|
108
|
+
if (!isSettingsDownloadFile(file))
|
|
109
|
+
throw new SettingsConflict("The selected file is not a download snapshot.");
|
|
110
|
+
const path = this.localBackupPath(file);
|
|
111
|
+
let target;
|
|
112
|
+
try {
|
|
113
|
+
const directory = await lstat(this.directory("backups"));
|
|
114
|
+
if (!directory.isDirectory() || directory.isSymbolicLink())
|
|
115
|
+
return { status: "failed", reason: "target_conflict" };
|
|
116
|
+
target = await this.handle(path, constants.O_RDONLY);
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
if (fileCode(error, "ENOENT")) {
|
|
120
|
+
try {
|
|
121
|
+
await this.downloadSourceIdentity(file);
|
|
122
|
+
return { status: "failed", reason: "not_published" };
|
|
123
|
+
}
|
|
124
|
+
catch (sourceError) {
|
|
125
|
+
if (sourceError instanceof SettingsConflict || fileCode(sourceError, "ENOENT") || fileCode(sourceError, "ELOOP"))
|
|
126
|
+
return { status: "failed", reason: "source_unavailable" };
|
|
127
|
+
throw sourceError;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (error instanceof SettingsConflict || fileCode(error, "ELOOP"))
|
|
131
|
+
return { status: "failed", reason: "target_conflict" };
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
if (!sourceIdentity)
|
|
136
|
+
return { status: "failed", reason: "target_conflict" };
|
|
137
|
+
const actualTarget = await this.verifiedIdentity(target, path, file);
|
|
138
|
+
if (!this.sameFile(sourceIdentity, actualTarget))
|
|
139
|
+
return { status: "failed", reason: "target_conflict" };
|
|
140
|
+
const actualSource = await this.downloadSourceIdentity(file);
|
|
141
|
+
return this.sameFile(sourceIdentity, actualSource) ? { status: "saved" } : { status: "failed", reason: "source_unavailable" };
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
if (error instanceof SettingsConflict || fileCode(error, "ENOENT") || fileCode(error, "ELOOP"))
|
|
145
|
+
return { status: "failed", reason: "source_unavailable" };
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
finally {
|
|
149
|
+
await target.close();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
async handle(path, flags) {
|
|
153
|
+
const file = await open(path, flags | constants.O_NOFOLLOW, 0o600);
|
|
154
|
+
try {
|
|
155
|
+
if (!(await file.stat()).isFile())
|
|
156
|
+
throw new SettingsConflict("The selected item is not a file that Sui MCP can read.");
|
|
157
|
+
return file;
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
await file.close();
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
async createUpload(file) {
|
|
165
|
+
if (file.role !== "upload" || file.invalidated || file.receivedSize !== 0 ||
|
|
166
|
+
file.relativePath !== `.settings-staging/${file.fileId}.upload`)
|
|
167
|
+
throw new SettingsConflict("This file transfer is no longer available. Choose the backup file again.");
|
|
168
|
+
await this.prepareDirectory("staging");
|
|
169
|
+
const handle = await this.handle(this.stagingPath(file), constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL);
|
|
170
|
+
try {
|
|
171
|
+
await handle.sync();
|
|
172
|
+
}
|
|
173
|
+
finally {
|
|
174
|
+
await handle.close();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
async writeReservedChunk(file, data) {
|
|
178
|
+
const reservation = file.reservation;
|
|
179
|
+
if (file.role !== "upload" || file.invalidated || file.sealed || !reservation || reservation.offset !== file.receivedSize ||
|
|
180
|
+
reservation.length !== data.length || reservation.sha256 !== settingsFileDigest(data) ||
|
|
181
|
+
reservation.offset + data.length > file.totalSize)
|
|
182
|
+
throw new SettingsConflict("The next part of the file does not match the expected content.");
|
|
183
|
+
await this.prepareDirectory("staging");
|
|
184
|
+
const handle = await this.handle(this.stagingPath(file), constants.O_RDWR);
|
|
185
|
+
try {
|
|
186
|
+
const length = (await handle.stat()).size;
|
|
187
|
+
if (length < reservation.offset || length > reservation.offset + data.length)
|
|
188
|
+
throw new SettingsConflict("The file size changed during transfer.");
|
|
189
|
+
const written = length - reservation.offset;
|
|
190
|
+
const prefix = Buffer.alloc(written);
|
|
191
|
+
const read = await handle.read(prefix, 0, written, reservation.offset);
|
|
192
|
+
if (read.bytesRead !== written || !prefix.equals(data.subarray(0, written)))
|
|
193
|
+
throw new SettingsConflict("The part of the file already received has changed.");
|
|
194
|
+
let offset = written;
|
|
195
|
+
while (offset < data.length) {
|
|
196
|
+
const result = await handle.write(data, offset, data.length - offset, reservation.offset + offset);
|
|
197
|
+
if (!result.bytesWritten)
|
|
198
|
+
throw new Error("Sui MCP could not save the next part of the file.");
|
|
199
|
+
offset += result.bytesWritten;
|
|
200
|
+
}
|
|
201
|
+
await handle.sync();
|
|
202
|
+
const actual = Buffer.alloc(data.length);
|
|
203
|
+
const verified = await handle.read(actual, 0, data.length, reservation.offset);
|
|
204
|
+
if (verified.bytesRead !== data.length || !actual.equals(data))
|
|
205
|
+
throw new SettingsConflict("The received part of the file could not be checked.");
|
|
206
|
+
}
|
|
207
|
+
finally {
|
|
208
|
+
await handle.close();
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
async readUpload(file) {
|
|
212
|
+
if (file.role !== "upload" || file.invalidated || file.receivedSize !== file.totalSize || file.reservation) {
|
|
213
|
+
throw new SettingsConflict("The complete file has not been received yet.");
|
|
214
|
+
}
|
|
215
|
+
await this.prepareDirectory("staging");
|
|
216
|
+
const handle = await this.handle(this.stagingPath(file), constants.O_RDONLY);
|
|
217
|
+
try {
|
|
218
|
+
if ((await handle.stat()).size !== file.totalSize)
|
|
219
|
+
throw new SettingsConflict("The received file size has changed.");
|
|
220
|
+
const data = Buffer.alloc(file.totalSize);
|
|
221
|
+
const actual = await handle.read(data, 0, data.length, 0);
|
|
222
|
+
if (actual.bytesRead !== data.length || settingsFileDigest(data) !== file.sha256)
|
|
223
|
+
throw new SettingsConflict("The received file has changed or is incomplete.");
|
|
224
|
+
return data;
|
|
225
|
+
}
|
|
226
|
+
finally {
|
|
227
|
+
await handle.close();
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
async verifyUploadPrefix(file, offset, data) {
|
|
231
|
+
if (file.role !== "upload" || file.invalidated || offset < 0 || offset + data.length > file.receivedSize) {
|
|
232
|
+
throw new SettingsConflict("This part of the file has not been received.");
|
|
233
|
+
}
|
|
234
|
+
await this.prepareDirectory("staging");
|
|
235
|
+
const handle = await this.handle(this.stagingPath(file), constants.O_RDONLY);
|
|
236
|
+
try {
|
|
237
|
+
const actual = Buffer.alloc(data.length), read = await handle.read(actual, 0, data.length, offset);
|
|
238
|
+
if (read.bytesRead !== data.length || !actual.equals(data))
|
|
239
|
+
throw new SettingsConflict("Different file content was already received at this position.");
|
|
240
|
+
}
|
|
241
|
+
finally {
|
|
242
|
+
await handle.close();
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
async createDownload(file, data) {
|
|
246
|
+
if (!isSettingsDownloadFile(file) || file.invalidated || data.length !== file.totalSize || settingsFileDigest(data) !== file.sha256) {
|
|
247
|
+
throw new SettingsConflict("The download snapshot does not match the selected records.");
|
|
248
|
+
}
|
|
249
|
+
await this.prepareDirectory("staging");
|
|
250
|
+
let handle;
|
|
251
|
+
try {
|
|
252
|
+
handle = await this.handle(this.stagingPath(file), constants.O_RDWR | constants.O_CREAT | constants.O_EXCL);
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
if (error && typeof error === "object" && "code" in error && error.code === "EEXIST") {
|
|
256
|
+
throw new SettingsDownloadPathExists("The reserved backup location already contains a file.");
|
|
257
|
+
}
|
|
258
|
+
throw error;
|
|
259
|
+
}
|
|
260
|
+
try {
|
|
261
|
+
await handle.writeFile(data);
|
|
262
|
+
await handle.sync();
|
|
263
|
+
if ((await handle.stat()).size !== file.totalSize)
|
|
264
|
+
throw new SettingsConflict("The download snapshot length changed.");
|
|
265
|
+
const hash = createHash("sha256");
|
|
266
|
+
for await (const chunk of handle.createReadStream({ start: 0, autoClose: false }))
|
|
267
|
+
hash.update(chunk);
|
|
268
|
+
if (hash.digest("hex") !== file.sha256)
|
|
269
|
+
throw new SettingsConflict("The download snapshot content changed.");
|
|
270
|
+
}
|
|
271
|
+
finally {
|
|
272
|
+
await handle.close();
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
async readDownloadRange(file, offset, length) {
|
|
276
|
+
if (!isSettingsDownloadFile(file))
|
|
277
|
+
throw new SettingsConflict("This file is not the private download snapshot.");
|
|
278
|
+
return this.readVerifiedRange(file, this.directory("staging"), this.stagingPath(file), offset, length);
|
|
279
|
+
}
|
|
280
|
+
async verifyExport(file) {
|
|
281
|
+
const directory = this.directory("backups");
|
|
282
|
+
const info = await lstat(directory);
|
|
283
|
+
if (!info.isDirectory() || info.isSymbolicLink())
|
|
284
|
+
throw new SettingsConflict("The local backup directory changed.");
|
|
285
|
+
const handle = await this.handle(this.finalPath(file), constants.O_RDONLY);
|
|
286
|
+
try {
|
|
287
|
+
if ((await handle.stat()).size !== file.totalSize)
|
|
288
|
+
throw new SettingsConflict("The local backup length changed.");
|
|
289
|
+
const hash = createHash("sha256");
|
|
290
|
+
for await (const chunk of handle.createReadStream({ autoClose: false }))
|
|
291
|
+
hash.update(chunk);
|
|
292
|
+
if (hash.digest("hex") !== file.sha256)
|
|
293
|
+
throw new SettingsConflict("The local backup content changed.");
|
|
294
|
+
}
|
|
295
|
+
finally {
|
|
296
|
+
await handle.close();
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
async readExportRange(file, offset, length) {
|
|
300
|
+
return this.readVerifiedRange(file, this.directory("backups"), this.finalPath(file), offset, length);
|
|
301
|
+
}
|
|
302
|
+
async readVerifiedRange(file, directory, path, offset, length) {
|
|
303
|
+
if (!file.sealed || file.invalidated || !Number.isSafeInteger(offset) || !Number.isSafeInteger(length) ||
|
|
304
|
+
offset < 0 || length < 1 || offset + length > file.totalSize)
|
|
305
|
+
throw new SettingsConflict("This part of the backup is unavailable.");
|
|
306
|
+
const info = await lstat(directory);
|
|
307
|
+
if (!info.isDirectory() || info.isSymbolicLink())
|
|
308
|
+
throw new SettingsConflict("The local backup directory changed.");
|
|
309
|
+
const handle = await this.handle(path, constants.O_RDONLY);
|
|
310
|
+
try {
|
|
311
|
+
if ((await handle.stat()).size !== file.totalSize)
|
|
312
|
+
throw new SettingsConflict("The local backup length changed.");
|
|
313
|
+
const hash = createHash("sha256");
|
|
314
|
+
for await (const chunk of handle.createReadStream({ autoClose: false }))
|
|
315
|
+
hash.update(chunk);
|
|
316
|
+
if (hash.digest("hex") !== file.sha256)
|
|
317
|
+
throw new SettingsConflict("The local backup content changed.");
|
|
318
|
+
const data = Buffer.alloc(length), actual = await handle.read(data, 0, length, offset);
|
|
319
|
+
if (actual.bytesRead !== length)
|
|
320
|
+
throw new SettingsConflict("The local backup read was incomplete.");
|
|
321
|
+
return data;
|
|
322
|
+
}
|
|
323
|
+
finally {
|
|
324
|
+
await handle.close();
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
async cleanup(file) {
|
|
328
|
+
await this.prepareDirectory("staging");
|
|
329
|
+
try {
|
|
330
|
+
await unlink(this.stagingPath(file));
|
|
331
|
+
}
|
|
332
|
+
catch (error) {
|
|
333
|
+
if (!(error && typeof error === "object" && "code" in error && error.code === "ENOENT"))
|
|
334
|
+
throw error;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { SuiEndpointError } from "../suiEndpoint.js";
|
|
2
|
+
import { LocalSettingsError } from "../preferences/preferencesStore.js";
|
|
3
|
+
import { LocalDataError } from "../activity/localDataTypes.js";
|
|
4
|
+
export const SETTINGS_REPLACEMENT_BLOCKED_MESSAGE = "A wallet or transaction response is still pending, or the initial transaction-result waiting period has not ended. Local records cannot be replaced yet.";
|
|
5
|
+
export function settingsInputError(error, kind) {
|
|
6
|
+
if (!(error instanceof SuiEndpointError) &&
|
|
7
|
+
!(error instanceof LocalSettingsError && error.kind === "input_invalid") &&
|
|
8
|
+
!(error instanceof LocalDataError && error.kind === "input_invalid"))
|
|
9
|
+
return undefined;
|
|
10
|
+
const reason = error instanceof SuiEndpointError ? error.kind : error.details.endpointReason ?? error.details.reason;
|
|
11
|
+
const failure = (restoreReason, message) => ({ restoreReason, message });
|
|
12
|
+
if (kind === "import_local_data") {
|
|
13
|
+
if (reason === "invalid_url" || reason === "chain_identifier_mismatch")
|
|
14
|
+
return failure("invalid_backup", "A network address saved in this backup could not be verified for Sui mainnet. Choose another Sui MCP backup with valid mainnet network settings.");
|
|
15
|
+
if (reason === "endpoint_timeout" || reason === "endpoint_unreachable")
|
|
16
|
+
return failure("check_failed", "Sui MCP could not reach a network address saved in this backup. Check that the service is reachable, then check this backup again.");
|
|
17
|
+
}
|
|
18
|
+
switch (reason) {
|
|
19
|
+
case "invalid_url": return failure("invalid_backup", "The network address is not a valid URL. Correct it and select it again.");
|
|
20
|
+
case "chain_identifier_mismatch": return failure("invalid_backup", "The network check did not confirm Sui mainnet at this address. Select a Sui mainnet network address.");
|
|
21
|
+
case "endpoint_timeout": return failure("check_failed", "The service at this network address did not respond in time. Check that it is reachable, then review the change again.");
|
|
22
|
+
case "endpoint_unreachable": return failure("check_failed", "The service at this network address could not be reached. Check the address and your connection, then review the change again.");
|
|
23
|
+
case "wallet_request_unsettled": return failure("wallet_request_unsettled", SETTINGS_REPLACEMENT_BLOCKED_MESSAGE);
|
|
24
|
+
case "invalid_backup_encoding": return failure("invalid_backup", "This backup contains text that Sui MCP cannot read. Choose another backup.");
|
|
25
|
+
case "invalid_backup_json": return failure("invalid_backup", "This backup's contents are incomplete or incorrectly formatted. Choose another backup.");
|
|
26
|
+
}
|
|
27
|
+
if (error instanceof LocalDataError)
|
|
28
|
+
return failure("invalid_backup", "This file is not a supported Sui MCP backup. Choose another backup.");
|
|
29
|
+
if (error instanceof LocalSettingsError)
|
|
30
|
+
return failure("check_failed", "This network address could not be checked. Check the address, then review the change again.");
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { assetReadAccountSchema, walletAvailabilitySchema } from "./walletConnection.js";
|
|
3
|
+
import { LOCAL_DATA_EXPORT_FORMAT } from "../activity/localDataTypes.js";
|
|
4
|
+
import { suiAddressStringSchema } from "../suiAddress.js";
|
|
5
|
+
export const SETTINGS_IMPORT_MAX_SIZE = 16 * 1024 * 1024;
|
|
6
|
+
const identity = z.string().uuid();
|
|
7
|
+
const size = z.number().int().nonnegative().safe();
|
|
8
|
+
const sha256 = z.string().regex(/^[a-f0-9]{64}$/);
|
|
9
|
+
export const restoreCheckSchema = z.discriminatedUnion("status", [
|
|
10
|
+
z.object({ status: z.literal("no_file") }).strict(),
|
|
11
|
+
z.object({ status: z.literal("receiving") }).strict(),
|
|
12
|
+
z.object({ status: z.literal("checking") }).strict(),
|
|
13
|
+
z.object({ status: z.literal("check_required"), message: z.string().min(1) }).strict(),
|
|
14
|
+
z.object({ status: z.literal("can_restore") }).strict(),
|
|
15
|
+
z.object({ status: z.literal("cannot_restore"), reason: z.enum([
|
|
16
|
+
"invalid_backup", "file_unavailable", "wallet_request_unsettled", "operation_busy", "input_unavailable", "check_failed"
|
|
17
|
+
]), message: z.string().min(1) }).strict()
|
|
18
|
+
]);
|
|
19
|
+
export const storedRestoreCheckSchema = z.object({ fileId: identity, sha256, totalSize: size, dataEpoch: size,
|
|
20
|
+
userDataRevision: size, check: restoreCheckSchema }).strict();
|
|
21
|
+
export const settingsDraftSchema = z.discriminatedUnion("kind", [
|
|
22
|
+
z.object({ kind: z.literal("set_endpoint"), endpoint: z.enum(["grpc", "graphql"]), value: z.string().min(1) }).strict(),
|
|
23
|
+
z.object({ kind: z.literal("restore_endpoint"), endpoint: z.enum(["grpc", "graphql"]) }).strict(),
|
|
24
|
+
z.object({ kind: z.enum(["clear_read_address", "export_local_data", "download_backup", "import_local_data", "reset_local_data", "restart_wallet_service"]) }).strict()
|
|
25
|
+
]);
|
|
26
|
+
export const settingsDownloadReportSchema = z.enum(["acknowledged", "not_completed", "unconfirmed", "unsupported"]);
|
|
27
|
+
export const settingsDownloadFailureSchema = z.object({ method: z.literal("ui/download-file"), code: z.literal(-32601) }).strict();
|
|
28
|
+
export const settingsDownloadEndSchema = z.enum(["acknowledged", "cancelled", "expired", "server_restarted", "data_replaced", "source_unavailable", "stored_locally", "local_save_failed"]);
|
|
29
|
+
export const settingsFileIdentitySchema = z.object({ device: z.string().regex(/^\d+$/), inode: z.string().regex(/^\d+$/) }).strict();
|
|
30
|
+
const localSaveFailure = z.enum(["not_published", "target_conflict", "source_unavailable", "interrupted"]);
|
|
31
|
+
const storedLocalSaveSchema = z.object({ status: z.enum(["needs_consent", "saving", "saved", "failed"]),
|
|
32
|
+
attemptId: identity, fileId: identity, sha256, totalSize: size, destination: z.string(), ownerId: z.string().min(1),
|
|
33
|
+
currentSaveAttemptId: identity.optional(),
|
|
34
|
+
attempts: z.array(z.object({ saveAttemptId: identity, previousSaveAttemptId: identity.optional(),
|
|
35
|
+
admittedAt: z.string().datetime(), allowPossibleCopy: z.boolean() }).strict()),
|
|
36
|
+
sourceIdentity: settingsFileIdentitySchema.optional(), savedAt: z.string().datetime().optional(), failure: localSaveFailure.optional()
|
|
37
|
+
}).strict().superRefine((value, context) => {
|
|
38
|
+
if (value.status === "needs_consent" ? value.attempts.length !== 0 || value.currentSaveAttemptId !== undefined :
|
|
39
|
+
!value.currentSaveAttemptId || !value.attempts.some((a) => a.saveAttemptId === value.currentSaveAttemptId)) {
|
|
40
|
+
context.addIssue({ code: "custom", message: "A local save must identify its admitted attempt." });
|
|
41
|
+
}
|
|
42
|
+
if (new Set(value.attempts.map((a) => a.saveAttemptId)).size !== value.attempts.length ||
|
|
43
|
+
(value.status === "saved") !== (value.savedAt !== undefined) || value.status === "saved" && !value.sourceIdentity ||
|
|
44
|
+
(value.status === "failed") !== (value.failure !== undefined)) {
|
|
45
|
+
context.addIssue({ code: "custom", message: "Local save evidence must match its state." });
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
export const storedSettingsDownloadSchema = z.object({
|
|
49
|
+
currentAttemptId: identity,
|
|
50
|
+
attempts: z.array(z.object({ attemptId: identity, admittedAt: z.string().datetime(), previousAttemptId: identity.optional(),
|
|
51
|
+
hostReport: settingsDownloadReportSchema.optional(), failure: settingsDownloadFailureSchema.optional() }).strict()).min(1),
|
|
52
|
+
readyAt: z.string().datetime().optional(), expiresAt: z.string().datetime().optional(), endReason: settingsDownloadEndSchema.optional(),
|
|
53
|
+
localSave: storedLocalSaveSchema.optional()
|
|
54
|
+
}).strict().superRefine((value, context) => {
|
|
55
|
+
if (new Set(value.attempts.map((attempt) => attempt.attemptId)).size !== value.attempts.length ||
|
|
56
|
+
!value.attempts.some((attempt) => attempt.attemptId === value.currentAttemptId) ||
|
|
57
|
+
(value.readyAt === undefined) !== (value.expiresAt === undefined)) {
|
|
58
|
+
context.addIssue({ code: "custom", message: "Download attempts and lifetime must identify one stored snapshot." });
|
|
59
|
+
}
|
|
60
|
+
if (value.endReason === "stored_locally" && value.localSave?.status !== "saved" ||
|
|
61
|
+
value.endReason === "local_save_failed" && value.localSave?.status !== "failed" ||
|
|
62
|
+
value.attempts.some((a) => (a.hostReport === "unsupported") !== (a.failure !== undefined)) ||
|
|
63
|
+
value.localSave && (value.localSave.attemptId !== value.currentAttemptId ||
|
|
64
|
+
!value.attempts.some((a) => a.attemptId === value.currentAttemptId && a.hostReport === "unsupported") ||
|
|
65
|
+
value.localSave.status === "saving" && value.endReason !== undefined ||
|
|
66
|
+
(value.localSave.status === "saved") !== (value.endReason === "stored_locally"))) {
|
|
67
|
+
context.addIssue({ code: "custom", message: "Local saving requires the current download's unsupported report." });
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
export const settingsDownloadFileActionSchema = z.enum(["settings_retry_download", "settings_report_download", "settings_cancel_download", "settings_store_backup"]);
|
|
71
|
+
export const settingsActionSchema = z.discriminatedUnion("action", [
|
|
72
|
+
z.object({ action: z.literal("settings_download_backup"), operationId: identity, attemptId: identity }).strict(),
|
|
73
|
+
z.object({ action: z.literal("settings_retry_download"), operationId: identity, fileId: identity,
|
|
74
|
+
previousAttemptId: identity, attemptId: identity }).strict(),
|
|
75
|
+
z.object({ action: z.literal("settings_report_download"), operationId: identity, fileId: identity,
|
|
76
|
+
attemptId: identity, status: settingsDownloadReportSchema, failure: settingsDownloadFailureSchema.optional() }).strict().refine((value) => (value.status === "unsupported") === (value.failure !== undefined), "Only unsupported downloads require the exact method failure."),
|
|
77
|
+
z.object({ action: z.literal("settings_store_backup"), operationId: identity, fileId: identity, attemptId: identity,
|
|
78
|
+
saveAttemptId: identity, previousSaveAttemptId: identity.optional(), allowPossibleCopy: z.boolean() }).strict(),
|
|
79
|
+
z.object({ action: z.literal("settings_cancel_download"), operationId: identity, fileId: identity, attemptId: identity }).strict(),
|
|
80
|
+
z.object({ action: z.literal("settings_select"), draft: settingsDraftSchema }).strict(),
|
|
81
|
+
z.object({ action: z.literal("settings_prepare"), operationId: identity }).strict(),
|
|
82
|
+
z.object({ action: z.literal("settings_begin_upload"), operationId: identity,
|
|
83
|
+
name: z.string().min(1), totalSize: size.max(SETTINGS_IMPORT_MAX_SIZE), sha256 }).strict(),
|
|
84
|
+
z.object({ action: z.literal("settings_upload_chunk"), operationId: identity, fileId: identity,
|
|
85
|
+
offset: size.max(SETTINGS_IMPORT_MAX_SIZE), chunkBase64: z.string() }).strict(),
|
|
86
|
+
z.object({ action: z.literal("settings_seal_upload"), operationId: identity, fileId: identity }).strict(),
|
|
87
|
+
z.object({ action: z.literal("settings_confirm"), operationId: identity, preparationId: identity }).strict(),
|
|
88
|
+
z.object({ action: z.literal("settings_back"), operationId: identity }).strict(),
|
|
89
|
+
z.object({ action: z.literal("settings_cancel") }).strict()
|
|
90
|
+
]);
|
|
91
|
+
export function retainedBackupRejection(operation, file) {
|
|
92
|
+
const saved = storedRestoreCheckSchema.safeParse(operation.preparation?.restoreCheck);
|
|
93
|
+
if (operation.draft?.kind !== "import_local_data" || !file || operation.fileId !== file.fileId ||
|
|
94
|
+
file.operationId !== operation.operationId || file.role !== "upload" || file.invalidated || !file.sealed ||
|
|
95
|
+
file.receivedSize !== file.totalSize || file.reservation || !saved.success ||
|
|
96
|
+
saved.data.fileId !== file.fileId || saved.data.sha256 !== file.sha256 || saved.data.totalSize !== file.totalSize ||
|
|
97
|
+
saved.data.dataEpoch !== operation.dataEpoch || saved.data.check.status !== "cannot_restore" ||
|
|
98
|
+
saved.data.check.reason !== "invalid_backup")
|
|
99
|
+
return undefined;
|
|
100
|
+
return saved.data;
|
|
101
|
+
}
|
|
102
|
+
export function permitsBackupRecheck(check) {
|
|
103
|
+
return check.status === "check_required" || check.status === "cannot_restore" &&
|
|
104
|
+
["wallet_request_unsettled", "operation_busy", "check_failed"].includes(check.reason);
|
|
105
|
+
}
|
|
106
|
+
export class SettingsConflict extends Error {
|
|
107
|
+
}
|
|
108
|
+
export const settingsResultSchema = z.object({
|
|
109
|
+
outcome: z.enum(["completed", "failed", "interrupted", "superseded"]), recordedAt: z.string().datetime(),
|
|
110
|
+
message: z.string().min(1), details: z.record(z.string(), z.unknown()).optional(), completionScope: z.literal("backup_snapshot").optional()
|
|
111
|
+
}).strict();
|
|
112
|
+
const endpointView = z.object({ storedValue: z.string(), effectiveValue: z.string(),
|
|
113
|
+
source: z.enum(["environment", "local_db", "builtin_default"]), pendingStoredValue: z.string().optional(),
|
|
114
|
+
appliesAfter: z.literal("mcp_server_restart").optional() }).strict();
|
|
115
|
+
export const endpointPreparationSchema = z.object({ endpoint: z.enum(["grpc", "graphql"]),
|
|
116
|
+
key: z.enum(["suiGrpcUrl", "suiGraphqlUrl"]), value: z.string().min(1), status: z.enum(["saved", "reset"]),
|
|
117
|
+
storedValue: z.string(), effectiveValue: z.string(), source: endpointView.shape.source }).strict().refine((target) => target.key === (target.endpoint === "grpc" ? "suiGrpcUrl" : "suiGraphqlUrl"), "The endpoint kind and preference key must agree.");
|
|
118
|
+
const count = z.number().int().nonnegative().safe();
|
|
119
|
+
const localDataCounts = z.object({ accounts: count, reviewSessions: count, reviewStateSnapshots: count, reviewStatusTransitions: count,
|
|
120
|
+
reviewExecutions: count, reviewRequests: count, outcomeUnknownRequests: count, externalActivityScans: count,
|
|
121
|
+
externalActivityTransactions: count, localSettings: count }).strict();
|
|
122
|
+
const importPreview = z.object({ status: z.literal("valid"), format: z.literal(LOCAL_DATA_EXPORT_FORMAT), network: z.literal("mainnet"),
|
|
123
|
+
exportedAt: z.string().datetime(), currentCounts: localDataCounts, incomingCounts: localDataCounts, willReplace: z.literal(true),
|
|
124
|
+
activeAccountChange: z.enum(["unchanged", "set", "cleared"]), currentReadAddress: suiAddressStringSchema.nullable(),
|
|
125
|
+
incomingReadAddress: suiAddressStringSchema.nullable(), restartRequiredAfterImport: z.boolean() }).strict();
|
|
126
|
+
export const settingsViewSchema = z.object({
|
|
127
|
+
kind: z.literal("settings"), operationId: identity,
|
|
128
|
+
phase: z.enum(["draft", "uploading", "sealed", "prepared", "executing", "stopping", "starting", "terminal"]),
|
|
129
|
+
draft: settingsDraftSchema.optional(), preparationId: identity.optional(), preparation: z.record(z.string(), z.unknown()).optional(),
|
|
130
|
+
file: z.object({ fileId: identity, displayName: z.string(), totalSize: size, receivedSize: size, sha256,
|
|
131
|
+
sealed: z.boolean(), cleanup: z.enum(["pending", "complete", "failed"]) }).strict().optional(),
|
|
132
|
+
download: z.object({ availability: z.enum(["preparing", "available", "ended"]), currentAttemptId: identity,
|
|
133
|
+
hostReport: settingsDownloadReportSchema.optional(), previousUnconfirmed: z.boolean(),
|
|
134
|
+
endReason: settingsDownloadEndSchema.optional(), expiresAt: z.string().datetime().optional(), remainingMs: size.optional(),
|
|
135
|
+
localSave: z.object({ status: z.enum(["needs_consent", "saving", "saved", "failed"]), currentSaveAttemptId: identity.optional(),
|
|
136
|
+
failure: localSaveFailure.optional(), savedAt: z.string().datetime().optional() }).strict().optional(),
|
|
137
|
+
allowedFileActions: z.array(settingsDownloadFileActionSchema) }).strict().optional(),
|
|
138
|
+
restoreCheck: restoreCheckSchema.optional(),
|
|
139
|
+
backupLocation: z.object({ directory: z.string().min(1), filename: z.string().min(1) }).strict().optional(),
|
|
140
|
+
result: settingsResultSchema.optional(),
|
|
141
|
+
temporaryFileCleanup: z.enum(["pending", "complete", "failed"]).optional(),
|
|
142
|
+
priorRunId: identity.optional(), nextRunId: identity.optional(), supersedesOperationId: identity.optional(), supersededByOperationId: identity.optional(),
|
|
143
|
+
allowedActions: z.array(z.enum(settingsActionSchema.options.map((option) => option.shape.action.value))), observe: z.boolean(),
|
|
144
|
+
facts: z.object({ server: z.object({ name: z.string(), version: z.string(), network: z.literal("mainnet") }).strict(),
|
|
145
|
+
localSettings: z.object({ suiGrpcUrl: endpointView, suiGraphqlUrl: endpointView }).strict(), readAddress: z.string().optional(),
|
|
146
|
+
assetReadAccount: assetReadAccountSchema, walletAvailability: walletAvailabilitySchema,
|
|
147
|
+
connectionReconciliationRequired: z.literal(true).optional(), observedAt: z.string().datetime().optional(),
|
|
148
|
+
counts: localDataCounts }).strict().optional()
|
|
149
|
+
}).strict().superRefine((value, context) => {
|
|
150
|
+
if (value.download && (value.draft?.kind !== "download_backup" || !value.file ||
|
|
151
|
+
value.download.availability === "available" && (!value.file.sealed || !value.result || value.download.endReason || !value.download.expiresAt) ||
|
|
152
|
+
value.download.availability === "ended" && !value.download.endReason)) {
|
|
153
|
+
context.addIssue({ code: "custom", message: "Download delivery must describe its own snapshot and lifetime." });
|
|
154
|
+
}
|
|
155
|
+
if (value.draft?.kind === "download_backup" && value.preparation !== undefined) {
|
|
156
|
+
context.addIssue({ code: "custom", message: "Private download preparation is not public preparation data." });
|
|
157
|
+
}
|
|
158
|
+
if (value.restoreCheck && (value.draft?.kind !== "import_local_data" || value.result)) {
|
|
159
|
+
context.addIssue({ code: "custom", message: "Restore checks belong to an uncompleted import preparation." });
|
|
160
|
+
}
|
|
161
|
+
if (value.restoreCheck?.status === "can_restore" && (value.phase !== "prepared" || !value.file?.sealed || !value.allowedActions.includes("settings_confirm"))) {
|
|
162
|
+
context.addIssue({ code: "custom", message: "Restore availability requires a sealed file and current confirmation." });
|
|
163
|
+
}
|
|
164
|
+
if (value.draft?.kind === "import_local_data" && value.restoreCheck?.status !== "can_restore" && value.allowedActions.includes("settings_confirm")) {
|
|
165
|
+
context.addIssue({ code: "custom", message: "An unchecked or blocked import cannot offer confirmation." });
|
|
166
|
+
}
|
|
167
|
+
if (value.restoreCheck?.status === "receiving" && (!value.file || value.file.sealed || !["draft", "uploading"].includes(value.phase))) {
|
|
168
|
+
context.addIssue({ code: "custom", message: "Receiving requires the selected unsealed upload." });
|
|
169
|
+
}
|
|
170
|
+
if (value.restoreCheck?.status === "check_required" && (!value.file?.sealed || value.phase !== "sealed")) {
|
|
171
|
+
context.addIssue({ code: "custom", message: "A new check requires the same complete sealed upload." });
|
|
172
|
+
}
|
|
173
|
+
if (value.restoreCheck?.status === "cannot_restore" && value.restoreCheck.reason === "invalid_backup" &&
|
|
174
|
+
(value.phase === "prepared" || value.allowedActions.includes("settings_prepare"))) {
|
|
175
|
+
context.addIssue({ code: "custom", message: "A rejected backup requires an explicit new selection." });
|
|
176
|
+
}
|
|
177
|
+
if (value.backupLocation && !(value.result?.outcome === "completed" && (value.draft?.kind === "export_local_data" ||
|
|
178
|
+
value.draft?.kind === "download_backup" && value.download?.localSave?.status === "saved" && value.download.endReason === "stored_locally"))) {
|
|
179
|
+
context.addIssue({ code: "custom", message: "A backup location requires its recorded successful export." });
|
|
180
|
+
}
|
|
181
|
+
if (value.download?.localSave?.status === "saved" && !value.backupLocation) {
|
|
182
|
+
context.addIssue({ code: "custom", message: "A saved backup must identify its verified location." });
|
|
183
|
+
}
|
|
184
|
+
if (value.draft?.kind === "export_local_data" && value.result?.outcome === "completed" &&
|
|
185
|
+
typeof value.result.details?.path === "string" && !value.backupLocation) {
|
|
186
|
+
context.addIssue({ code: "custom", message: "A recorded backup path must provide its folder and file name." });
|
|
187
|
+
}
|
|
188
|
+
if (value.temporaryFileCleanup && !value.result)
|
|
189
|
+
context.addIssue({ code: "custom", message: "Cleanup of an applied or ended operation requires its recorded result." });
|
|
190
|
+
if ((value.phase === "terminal") !== (value.result !== undefined) || value.phase === "terminal" && (value.observe || value.allowedActions.length)) {
|
|
191
|
+
context.addIssue({ code: "custom", message: "A terminal Settings result cannot grant input or observation." });
|
|
192
|
+
}
|
|
193
|
+
if (value.phase === "prepared" && (!value.preparationId || !value.draft || !value.preparation))
|
|
194
|
+
context.addIssue({ code: "custom", message: "Settings confirmation requires a prepared identity and facts." });
|
|
195
|
+
if (value.phase === "prepared" && value.draft?.kind === "import_local_data" && !importPreview.safeParse(value.preparation?.preview).success) {
|
|
196
|
+
context.addIssue({ code: "custom", path: ["preparation", "preview"], message: "Import confirmation requires the validated current and incoming read context and record counts." });
|
|
197
|
+
}
|
|
198
|
+
if (value.phase === "prepared" && (value.draft?.kind === "set_endpoint" || value.draft?.kind === "restore_endpoint")) {
|
|
199
|
+
const target = endpointPreparationSchema.safeParse(value.preparation?.endpoint);
|
|
200
|
+
if (!target.success || target.data.endpoint !== value.draft.endpoint ||
|
|
201
|
+
target.data.status !== (value.draft.kind === "set_endpoint" ? "saved" : "reset") ||
|
|
202
|
+
value.draft.kind === "set_endpoint" && target.data.value !== value.draft.value) {
|
|
203
|
+
context.addIssue({ code: "custom", path: ["preparation", "endpoint"], message: "Endpoint confirmation must name its exact prepared target." });
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (value.file && value.file.receivedSize > value.file.totalSize)
|
|
207
|
+
context.addIssue({ code: "custom", message: "Received content exceeds the selected file." });
|
|
208
|
+
});
|
|
209
|
+
export function settingsDownload(operation) {
|
|
210
|
+
if (operation.draft?.kind !== "download_backup")
|
|
211
|
+
return undefined;
|
|
212
|
+
const download = storedSettingsDownloadSchema.parse(operation.preparation?.download), save = download.localSave;
|
|
213
|
+
if (save && (save.fileId !== operation.fileId || save.destination !== `backups/${operation.operationId}.json`)) {
|
|
214
|
+
throw new SettingsConflict("The local save does not identify this request's backup.");
|
|
215
|
+
}
|
|
216
|
+
return download;
|
|
217
|
+
}
|
|
218
|
+
export function isSettingsDownloadFileAction(action) {
|
|
219
|
+
return settingsDownloadFileActionSchema.safeParse(action.action).success;
|
|
220
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export const SETTINGS_REUSABLE_UPLOAD_SQL = `
|
|
2
|
+
kind='import_local_data' AND EXISTS (
|
|
3
|
+
SELECT 1 FROM settings_files f
|
|
4
|
+
WHERE f.file_id=settings_operations.file_id
|
|
5
|
+
AND f.operation_id=settings_operations.operation_id AND f.role='upload'
|
|
6
|
+
AND f.sealed=1 AND f.invalidated=0 AND f.received_size=f.total_size
|
|
7
|
+
AND f.reservation_json IS NULL
|
|
8
|
+
)`;
|
|
9
|
+
export const SETTINGS_RESET_PREPARATION_SQL = `
|
|
10
|
+
UPDATE settings_operations SET
|
|
11
|
+
phase=CASE WHEN ${SETTINGS_REUSABLE_UPLOAD_SQL} THEN 'sealed' ELSE 'draft' END,
|
|
12
|
+
preparation_id=NULL,fingerprint=NULL,preparation_json=NULL,
|
|
13
|
+
prepared_user_data_revision=NULL`;
|