artifacty 0.11.1 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +10 -4
- package/README.md +164 -24
- package/SECURITY.md +25 -16
- package/docs/artifact-schema-v1.md +73 -50
- package/docs/central-team-deployment-design.md +56 -52
- package/docs/integrations.md +29 -25
- package/docs/mcp-public-api.md +75 -57
- package/docs/network-sharing.md +8 -8
- package/docs/release-checklist.md +29 -13
- package/docs/roadmap-design.md +145 -133
- package/docs/sarif-csv-artifact-plan.md +25 -26
- package/docs/threat-model.md +121 -100
- package/docs/upgrade-backups.md +89 -0
- package/docs/writing-style.md +56 -0
- package/package.json +5 -3
- package/src/cli.js +78 -3
- package/src/lib/backup.js +19 -6
- package/src/lib/doctor.js +1 -1
- package/src/lib/i18n.js +142 -0
- package/src/lib/network-policy.js +234 -0
- package/src/lib/render.js +142 -28
- package/src/lib/sample-content.js +191 -0
- package/src/lib/samples.js +74 -0
- package/src/lib/share-links.js +136 -0
- package/src/lib/storage.js +458 -45
- package/src/lib/upgrade-backup.js +417 -0
- package/src/mcp-server.js +18 -6
- package/src/server.js +198 -7
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
import { randomUUID, createHash } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
closeSync, copyFileSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync,
|
|
4
|
+
readFileSync, readdirSync, renameSync, rmSync, writeFileSync, chmodSync
|
|
5
|
+
} from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { DatabaseSync } from "node:sqlite";
|
|
8
|
+
|
|
9
|
+
const APP_VERSION = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version;
|
|
10
|
+
const KIND = "artifacty-upgrade-backup";
|
|
11
|
+
const ID_PATTERN = /^(auto|manual|recovery)-[0-9T-]+-[a-f0-9-]{36}$/;
|
|
12
|
+
const wait = new Int32Array(new SharedArrayBuffer(4));
|
|
13
|
+
|
|
14
|
+
function error(message, code = "UPGRADE_BACKUP_FAILED") {
|
|
15
|
+
return Object.assign(new Error(message), { code });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function paths(store) {
|
|
19
|
+
const home = path.resolve(store.home);
|
|
20
|
+
return { home, dbPath: path.join(home, "artifacty.sqlite"), indexPath: path.join(home, "index.json"), artifactsDir: path.join(home, "artifacts"), backups: path.join(home, "upgrade-backups") };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function safePath(root, relative) {
|
|
24
|
+
if (typeof relative !== "string" || !relative || path.isAbsolute(relative) || relative.includes("\\") || relative.split("/").some((part) => !part || part === "." || part === "..")) {
|
|
25
|
+
throw error(`Unsafe snapshot path: ${relative}`);
|
|
26
|
+
}
|
|
27
|
+
const target = path.resolve(root, relative);
|
|
28
|
+
if (!target.startsWith(`${path.resolve(root)}${path.sep}`)) throw error("Snapshot path escapes its root");
|
|
29
|
+
let current = root;
|
|
30
|
+
for (const part of relative.split("/")) {
|
|
31
|
+
current = path.join(current, part);
|
|
32
|
+
try {
|
|
33
|
+
if (lstatSync(current).isSymbolicLink()) throw error(`Snapshot paths must not contain symbolic links: ${current}`);
|
|
34
|
+
} catch (cause) { if (cause.code !== "ENOENT") throw cause; }
|
|
35
|
+
}
|
|
36
|
+
return target;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function privateDirectory(directory) {
|
|
40
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
41
|
+
const stat = lstatSync(directory);
|
|
42
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw error(`Expected a real directory: ${directory}`);
|
|
43
|
+
chmodSync(directory, 0o700);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function alive(pid) {
|
|
47
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
48
|
+
try { process.kill(pid, 0); return true; }
|
|
49
|
+
catch (cause) { return cause.code !== "ESRCH"; }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function assertOffline(store, allowOwnPid = false) {
|
|
53
|
+
for (const filename of ["server.pid", "server.json"]) {
|
|
54
|
+
const file = safePath(store.home, filename);
|
|
55
|
+
if (!existsSync(file)) continue;
|
|
56
|
+
let pid;
|
|
57
|
+
try {
|
|
58
|
+
const text = readFileSync(file, "utf8");
|
|
59
|
+
pid = filename.endsWith(".json") ? JSON.parse(text).pid : Number(text.trim());
|
|
60
|
+
} catch { throw error(`Cannot inspect ${filename}; stop the server and repair this marker first`, "STORE_NOT_OFFLINE"); }
|
|
61
|
+
if (alive(pid) && !(allowOwnPid && pid === process.pid)) throw error("Stop the managed Artifacty server before upgrading or restoring this store", "STORE_NOT_OFFLINE");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function durableWrite(file, content) {
|
|
66
|
+
const fd = openSync(file, "wx", 0o600);
|
|
67
|
+
try { writeFileSync(fd, content); fsyncSync(fd); } finally { closeSync(fd); }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function syncDirectory(directory) {
|
|
71
|
+
if (process.platform === "win32") return;
|
|
72
|
+
const fd = openSync(directory, "r");
|
|
73
|
+
try { fsyncSync(fd); } finally { closeSync(fd); }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function syncTree(directory) {
|
|
77
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
78
|
+
const file = path.join(directory, entry.name);
|
|
79
|
+
if (entry.isDirectory()) syncTree(file);
|
|
80
|
+
else {
|
|
81
|
+
const fd = openSync(file, "r+");
|
|
82
|
+
try { fsyncSync(fd); } finally { closeSync(fd); }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
syncDirectory(directory);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// A separate reaper guard serializes removal of dead-owner locks. A malformed
|
|
89
|
+
// lock or an abandoned reaper fails closed; it is never guessed to be stale.
|
|
90
|
+
function lockStore(store) {
|
|
91
|
+
privateDirectory(store.home);
|
|
92
|
+
const lock = safePath(store.home, ".upgrade.lock");
|
|
93
|
+
const reaper = safePath(store.home, ".upgrade-reaper");
|
|
94
|
+
const token = randomUUID();
|
|
95
|
+
const deadline = Date.now() + 5000;
|
|
96
|
+
const acquire = () => {
|
|
97
|
+
durableWrite(lock, JSON.stringify({ pid: process.pid, token, createdAt: new Date().toISOString() }));
|
|
98
|
+
return () => {
|
|
99
|
+
const current = JSON.parse(readFileSync(lock, "utf8"));
|
|
100
|
+
if (current.token !== token) throw error("Store lock ownership changed");
|
|
101
|
+
rmSync(lock);
|
|
102
|
+
};
|
|
103
|
+
};
|
|
104
|
+
while (true) {
|
|
105
|
+
if (!existsSync(reaper)) {
|
|
106
|
+
try { return acquire(); } catch (cause) { if (cause.code !== "EEXIST") throw cause; }
|
|
107
|
+
let owner;
|
|
108
|
+
try { owner = JSON.parse(readFileSync(lock, "utf8")); }
|
|
109
|
+
catch (cause) {
|
|
110
|
+
if (cause.code === "ENOENT") continue;
|
|
111
|
+
if (Date.now() >= deadline) throw error("Unreadable upgrade lock; inspect .upgrade.lock before retrying", "STORE_LOCKED");
|
|
112
|
+
Atomics.wait(wait, 0, 0, 25);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (!Number.isInteger(owner.pid) || !owner.token) throw error("Invalid upgrade lock; inspect .upgrade.lock before retrying", "STORE_LOCKED");
|
|
116
|
+
if (!alive(owner.pid)) {
|
|
117
|
+
try { mkdirSync(reaper, { mode: 0o700 }); }
|
|
118
|
+
catch (cause) { if (cause.code === "EEXIST") continue; throw cause; }
|
|
119
|
+
try {
|
|
120
|
+
if (existsSync(lock)) {
|
|
121
|
+
const current = JSON.parse(readFileSync(lock, "utf8"));
|
|
122
|
+
if (current.token === owner.token && !alive(current.pid)) rmSync(lock);
|
|
123
|
+
}
|
|
124
|
+
if (!existsSync(lock)) return acquire();
|
|
125
|
+
} finally { rmSync(reaper, { recursive: true }); }
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (Date.now() >= deadline) throw error("Store upgrade or restore is already running; inspect .upgrade.lock and .upgrade-reaper if its owner has stopped", "STORE_LOCKED");
|
|
129
|
+
Atomics.wait(wait, 0, 0, 25);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function rawState(store) {
|
|
134
|
+
safePath(store.home, "artifacty.sqlite");
|
|
135
|
+
safePath(store.home, "index.json");
|
|
136
|
+
if (!existsSync(store.dbPath) || lstatSync(store.dbPath).size === 0) return { schemaVersion: null, appVersion: null, hasDatabase: false, hasData: existsSync(store.indexPath) };
|
|
137
|
+
safePath(store.home, "artifacty.sqlite");
|
|
138
|
+
const db = new DatabaseSync(store.dbPath, { readOnly: true });
|
|
139
|
+
try {
|
|
140
|
+
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").all();
|
|
141
|
+
const hasMeta = tables.some((table) => table.name === "meta");
|
|
142
|
+
const schema = hasMeta ? db.prepare("SELECT value FROM meta WHERE key='store_version'").get()?.value : null;
|
|
143
|
+
const app = hasMeta ? db.prepare("SELECT value FROM meta WHERE key='application_version'").get()?.value : null;
|
|
144
|
+
if (schema != null && (!/^\d+$/.test(String(schema)) || !Number.isSafeInteger(Number(schema)))) throw error("Invalid stored schema version");
|
|
145
|
+
return { schemaVersion: schema == null ? null : Number(schema), appVersion: app || null, hasDatabase: true, hasData: tables.length > 0 || existsSync(store.indexPath) };
|
|
146
|
+
} finally { db.close(); }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function fileInfo(file) {
|
|
150
|
+
const stat = lstatSync(file);
|
|
151
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw error(`Expected a regular file: ${file}`);
|
|
152
|
+
return { sizeBytes: stat.size, sha256: createHash("sha256").update(readFileSync(file)).digest("hex") };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function references(database, indexFile) {
|
|
156
|
+
const refs = new Map();
|
|
157
|
+
const add = (row) => {
|
|
158
|
+
const versionPath = typeof row.path === "string" ? row.path.replaceAll("\\", "/") : "";
|
|
159
|
+
if (!versionPath.startsWith("artifacts/")) throw error("Version path must be inside artifacts/");
|
|
160
|
+
const expected = { path: versionPath, sizeBytes: row.size_bytes ?? row.sizeBytes, sha256: row.sha256 };
|
|
161
|
+
if (!Number.isSafeInteger(expected.sizeBytes) || expected.sizeBytes < 0 || !/^[a-f0-9]{64}$/i.test(expected.sha256 || "")) throw error(`Invalid version size or hash: ${row.path}`);
|
|
162
|
+
const previous = refs.get(versionPath);
|
|
163
|
+
if (previous && (previous.sha256 !== expected.sha256 || previous.sizeBytes !== expected.sizeBytes)) throw error("Conflicting version file references");
|
|
164
|
+
refs.set(versionPath, expected);
|
|
165
|
+
};
|
|
166
|
+
let legacyAlreadyImported = false;
|
|
167
|
+
if (database) {
|
|
168
|
+
const db = new DatabaseSync(database, { readOnly: true });
|
|
169
|
+
try {
|
|
170
|
+
const result = db.prepare("PRAGMA integrity_check").all();
|
|
171
|
+
if (result.length !== 1 || Object.values(result[0])[0] !== "ok") throw error("Snapshot SQLite integrity check failed");
|
|
172
|
+
if (db.prepare("SELECT name FROM sqlite_master WHERE name='artifact_versions'").get()) {
|
|
173
|
+
for (const row of db.prepare("SELECT path, size_bytes, sha256 FROM artifact_versions").all()) add(row);
|
|
174
|
+
}
|
|
175
|
+
if (db.prepare("SELECT name FROM sqlite_master WHERE name='artifacts'").get()) {
|
|
176
|
+
legacyAlreadyImported = db.prepare("SELECT COUNT(*) AS count FROM artifacts").get().count > 0;
|
|
177
|
+
}
|
|
178
|
+
if (db.prepare("SELECT name FROM sqlite_master WHERE name='meta'").get()) {
|
|
179
|
+
legacyAlreadyImported ||= db.prepare("SELECT value FROM meta WHERE key='json_index_migrated'").get()?.value === "true";
|
|
180
|
+
}
|
|
181
|
+
} finally { db.close(); }
|
|
182
|
+
}
|
|
183
|
+
// A legacy index is authoritative only before its import into SQLite.
|
|
184
|
+
if (indexFile && !legacyAlreadyImported) {
|
|
185
|
+
const index = JSON.parse(readFileSync(indexFile, "utf8"));
|
|
186
|
+
if (!Array.isArray(index.artifacts)) throw error("Invalid legacy index");
|
|
187
|
+
for (const artifact of index.artifacts) for (const version of artifact.versions || []) add(version);
|
|
188
|
+
}
|
|
189
|
+
return [...refs.values()].sort((a, b) => a.path.localeCompare(b.path));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function compareFile(root, expected) {
|
|
193
|
+
const actual = fileInfo(safePath(root, expected.path));
|
|
194
|
+
if (actual.sizeBytes !== expected.sizeBytes || actual.sha256 !== expected.sha256) throw error(`Snapshot file verification failed: ${expected.path}`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function snapshotDirectory(store, id) {
|
|
198
|
+
if (!ID_PATTERN.test(id || "")) throw error("Invalid snapshot id");
|
|
199
|
+
return safePath(store.home, `upgrade-backups/${id}`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function manifestAt(store, id) {
|
|
203
|
+
const directory = snapshotDirectory(store, id);
|
|
204
|
+
const manifest = JSON.parse(readFileSync(safePath(directory, "manifest.json"), "utf8"));
|
|
205
|
+
if (manifest.kind !== KIND || manifest.manifestVersion !== 1 || manifest.id !== id || !Array.isArray(manifest.files)) throw error("Invalid snapshot manifest");
|
|
206
|
+
const seen = new Set();
|
|
207
|
+
for (const file of manifest.files) {
|
|
208
|
+
if (!file || (file.path !== "artifacty.sqlite" && file.path !== "index.json" && !file.path?.startsWith("artifacts/")) || seen.has(file.path)) throw error("Invalid snapshot file entry");
|
|
209
|
+
safePath(directory, file.path);
|
|
210
|
+
if (!Number.isSafeInteger(file.sizeBytes) || file.sizeBytes < 0 || !/^[a-f0-9]{64}$/i.test(file.sha256 || "")) throw error("Invalid snapshot file hash or size");
|
|
211
|
+
seen.add(file.path);
|
|
212
|
+
}
|
|
213
|
+
return { directory, manifest };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function verifyUpgradeBackup(input, id) {
|
|
217
|
+
const store = paths(input);
|
|
218
|
+
const { directory, manifest } = manifestAt(store, id);
|
|
219
|
+
for (const file of manifest.files) compareFile(directory, file);
|
|
220
|
+
const database = manifest.files.some((file) => file.path === "artifacty.sqlite") ? safePath(directory, "artifacty.sqlite") : null;
|
|
221
|
+
const index = manifest.files.some((file) => file.path === "index.json") ? safePath(directory, "index.json") : null;
|
|
222
|
+
if (!database && !index && !manifest.emptyStore) throw error("Snapshot has no database or legacy index");
|
|
223
|
+
const refs = references(database, index);
|
|
224
|
+
const listed = manifest.files.filter((file) => file.path.startsWith("artifacts/"));
|
|
225
|
+
if (JSON.stringify(refs) !== JSON.stringify(listed)) throw error("Snapshot manifest does not match database file references");
|
|
226
|
+
return { id, valid: true, filesVerified: manifest.files.length, ...manifest, path: directory };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function listUpgradeBackups(input) {
|
|
230
|
+
const store = paths(input);
|
|
231
|
+
safePath(store.home, "upgrade-backups");
|
|
232
|
+
if (!existsSync(store.backups)) return [];
|
|
233
|
+
return readdirSync(store.backups).filter((id) => ID_PATTERN.test(id)).map((id) => {
|
|
234
|
+
try { const { directory, manifest } = manifestAt(store, id); return { ...manifest, path: directory }; }
|
|
235
|
+
catch (cause) { return { id, valid: false, error: cause.message }; }
|
|
236
|
+
}).sort((a, b) => String(b.createdAt || b.id).localeCompare(String(a.createdAt || a.id)));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function retentionCount() {
|
|
240
|
+
const value = process.env.ARTIFACTY_UPGRADE_BACKUP_KEEP || "3";
|
|
241
|
+
if (!/^\d+$/.test(value) || Number(value) < 1 || Number(value) > 1000) throw error("ARTIFACTY_UPGRADE_BACKUP_KEEP must be from 1 to 1000");
|
|
242
|
+
return Number(value);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function pruneAutomatic(store) {
|
|
246
|
+
const keep = retentionCount();
|
|
247
|
+
const automatic = listUpgradeBackups(store).filter((item) => item.id.startsWith("auto-") && item.kind === KIND && ["schema-upgrade", "application-update"].includes(item.reason));
|
|
248
|
+
for (const item of automatic.slice(keep)) {
|
|
249
|
+
verifyUpgradeBackup(store, item.id);
|
|
250
|
+
rmSync(snapshotDirectory(store, item.id), { recursive: true });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function createSnapshot(store, options = {}) {
|
|
255
|
+
const state = rawState(store);
|
|
256
|
+
const reason = options.reason || "manual";
|
|
257
|
+
const prefix = reason === "recovery" ? "recovery" : ["schema-upgrade", "application-update"].includes(reason) ? "auto" : "manual";
|
|
258
|
+
const id = `${prefix}-${new Date().toISOString().replace(/[:.Z]/g, "-")}-${randomUUID()}`;
|
|
259
|
+
privateDirectory(safePath(store.home, "upgrade-backups"));
|
|
260
|
+
const pending = safePath(store.backups, `.pending-${randomUUID()}`);
|
|
261
|
+
privateDirectory(pending);
|
|
262
|
+
let guard;
|
|
263
|
+
let published;
|
|
264
|
+
let complete = false;
|
|
265
|
+
try {
|
|
266
|
+
const files = [];
|
|
267
|
+
if (state.hasDatabase) {
|
|
268
|
+
guard = new DatabaseSync(store.dbPath);
|
|
269
|
+
guard.exec("PRAGMA busy_timeout=5000; BEGIN IMMEDIATE");
|
|
270
|
+
const source = new DatabaseSync(store.dbPath, { readOnly: true });
|
|
271
|
+
try { source.prepare("VACUUM INTO ?").run(path.join(pending, "artifacty.sqlite")); }
|
|
272
|
+
finally { source.close(); }
|
|
273
|
+
chmodSync(path.join(pending, "artifacty.sqlite"), 0o600);
|
|
274
|
+
files.push({ path: "artifacty.sqlite", ...fileInfo(path.join(pending, "artifacty.sqlite")) });
|
|
275
|
+
}
|
|
276
|
+
if (existsSync(store.indexPath)) {
|
|
277
|
+
const original = safePath(store.home, "index.json");
|
|
278
|
+
copyFileSync(original, path.join(pending, "index.json"));
|
|
279
|
+
chmodSync(path.join(pending, "index.json"), 0o600);
|
|
280
|
+
files.push({ path: "index.json", ...fileInfo(path.join(pending, "index.json")) });
|
|
281
|
+
}
|
|
282
|
+
const refs = references(state.hasDatabase ? path.join(pending, "artifacty.sqlite") : null, existsSync(path.join(pending, "index.json")) ? path.join(pending, "index.json") : null);
|
|
283
|
+
for (const ref of refs) {
|
|
284
|
+
const source = safePath(store.home, ref.path);
|
|
285
|
+
const target = safePath(pending, ref.path);
|
|
286
|
+
privateDirectory(path.dirname(target));
|
|
287
|
+
copyFileSync(source, target);
|
|
288
|
+
chmodSync(target, 0o600);
|
|
289
|
+
compareFile(pending, ref);
|
|
290
|
+
files.push(ref);
|
|
291
|
+
}
|
|
292
|
+
if (prefix === "auto") {
|
|
293
|
+
const previous = listUpgradeBackups(store).find((item) => item.reason === reason
|
|
294
|
+
&& item.sourceSchemaVersion === state.schemaVersion && item.targetSchemaVersion === (options.targetSchemaVersion ?? null)
|
|
295
|
+
&& item.sourceAppVersion === state.appVersion && item.targetAppVersion === (options.targetAppVersion ?? null)
|
|
296
|
+
&& JSON.stringify(item.files) === JSON.stringify(files));
|
|
297
|
+
if (previous) {
|
|
298
|
+
try { return verifyUpgradeBackup(store, previous.id); } catch { /* Keep damaged backups for inspection; create a verified replacement. */ }
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
const manifest = { kind: KIND, manifestVersion: 1, id, reason, createdAt: new Date().toISOString(), sourceSchemaVersion: state.schemaVersion, targetSchemaVersion: options.targetSchemaVersion ?? null, sourceAppVersion: state.appVersion, targetAppVersion: options.targetAppVersion ?? null, emptyStore: !state.hasData && !state.hasDatabase, files };
|
|
302
|
+
durableWrite(path.join(pending, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
303
|
+
syncTree(pending);
|
|
304
|
+
const destination = snapshotDirectory(store, id);
|
|
305
|
+
renameSync(pending, destination);
|
|
306
|
+
published = destination;
|
|
307
|
+
syncDirectory(store.backups);
|
|
308
|
+
const verified = verifyUpgradeBackup(store, id);
|
|
309
|
+
complete = true;
|
|
310
|
+
return verified;
|
|
311
|
+
} finally {
|
|
312
|
+
if (guard) { try { guard.exec("ROLLBACK"); } finally { guard.close(); } }
|
|
313
|
+
if (existsSync(pending)) rmSync(pending, { recursive: true, force: true });
|
|
314
|
+
if (published && !complete) rmSync(published, { recursive: true, force: true });
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function assertNoInterruptedRestore(store) {
|
|
319
|
+
const pending = readdirSync(store.home).find((name) => /^\.restore-[a-f0-9-]{36}$/.test(name));
|
|
320
|
+
if (pending) throw error(`Interrupted restore found at ${path.join(store.home, pending)}. Preserve this directory and the damaged store. Use its restore.json recoveryBackupId to restore into a new empty home.`, "RESTORE_INCOMPLETE");
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function createUpgradeBackup(input, options = {}) {
|
|
324
|
+
const store = paths(input);
|
|
325
|
+
const release = lockStore(store);
|
|
326
|
+
try { assertNoInterruptedRestore(store); assertOffline(store); return createSnapshot(store, options); }
|
|
327
|
+
finally { release(); }
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// The lock spans the snapshot, migrations, and application-version marker.
|
|
331
|
+
// Existing application handles must be stopped separately before an upgrade.
|
|
332
|
+
export function openProtectedDatabase(input, targetVersion, initialize) {
|
|
333
|
+
const store = paths(input);
|
|
334
|
+
const release = lockStore(store);
|
|
335
|
+
let db;
|
|
336
|
+
try {
|
|
337
|
+
assertNoInterruptedRestore(store);
|
|
338
|
+
const state = rawState(store);
|
|
339
|
+
if (state.schemaVersion > targetVersion) throw error(`Artifacty store was written by a newer version (store_version ${state.schemaVersion}) than this build supports (STORE_VERSION ${targetVersion}). Upgrade artifacty before opening this store.`, "NEWER_STORE_VERSION");
|
|
340
|
+
const schemaUpgrade = state.hasData && (state.schemaVersion == null || state.schemaVersion < targetVersion);
|
|
341
|
+
const appUpdate = state.hasData && process.env.ARTIFACTY_BACKUP_ON_UPDATE === "true" && state.appVersion !== APP_VERSION;
|
|
342
|
+
if (schemaUpgrade || appUpdate) {
|
|
343
|
+
retentionCount();
|
|
344
|
+
assertOffline(store, true);
|
|
345
|
+
createSnapshot(store, { reason: schemaUpgrade ? "schema-upgrade" : "application-update", targetSchemaVersion: targetVersion, targetAppVersion: APP_VERSION });
|
|
346
|
+
}
|
|
347
|
+
privateDirectory(store.artifactsDir);
|
|
348
|
+
db = new DatabaseSync(store.dbPath);
|
|
349
|
+
initialize(db);
|
|
350
|
+
if (state.appVersion !== APP_VERSION) db.prepare("INSERT OR REPLACE INTO meta (key,value) VALUES ('application_version', ?)").run(APP_VERSION);
|
|
351
|
+
if (schemaUpgrade || appUpdate) pruneAutomatic(store);
|
|
352
|
+
return db;
|
|
353
|
+
} catch (cause) { db?.close(); throw cause; }
|
|
354
|
+
finally { release(); }
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export function restoreUpgradeBackup(input, id, { confirm } = {}) {
|
|
358
|
+
if (confirm !== "restore-store") throw error('Restore requires confirm: "restore-store"', "RESTORE_CONFIRMATION_REQUIRED");
|
|
359
|
+
const store = paths(input);
|
|
360
|
+
const release = lockStore(store);
|
|
361
|
+
let stage;
|
|
362
|
+
let preserveStage = false;
|
|
363
|
+
try {
|
|
364
|
+
assertNoInterruptedRestore(store);
|
|
365
|
+
assertOffline(store);
|
|
366
|
+
const snapshot = verifyUpgradeBackup(store, id);
|
|
367
|
+
let recovery;
|
|
368
|
+
try { recovery = createSnapshot(store, { reason: "recovery" }); }
|
|
369
|
+
catch (cause) {
|
|
370
|
+
throw error(`Cannot verify the current store for a recovery snapshot: ${cause.message}. Preserve the damaged store and copy the selected snapshot into a new empty home's upgrade-backups directory, then restore there.`, "RECOVERY_BACKUP_FAILED");
|
|
371
|
+
}
|
|
372
|
+
stage = safePath(store.home, `.restore-${randomUUID()}`);
|
|
373
|
+
privateDirectory(stage);
|
|
374
|
+
privateDirectory(path.join(stage, "artifacts"));
|
|
375
|
+
for (const file of snapshot.files) {
|
|
376
|
+
const target = safePath(stage, file.path);
|
|
377
|
+
privateDirectory(path.dirname(target));
|
|
378
|
+
copyFileSync(safePath(snapshot.path, file.path), target);
|
|
379
|
+
chmodSync(target, 0o600);
|
|
380
|
+
compareFile(stage, file);
|
|
381
|
+
}
|
|
382
|
+
const previous = path.join(stage, "previous");
|
|
383
|
+
privateDirectory(previous);
|
|
384
|
+
const names = ["artifacty.sqlite", "artifacty.sqlite-wal", "artifacty.sqlite-shm", "index.json", "artifacts"];
|
|
385
|
+
const moved = [];
|
|
386
|
+
const installed = [];
|
|
387
|
+
durableWrite(path.join(stage, "restore.json"), JSON.stringify({ snapshotId: id, recoveryBackupId: recovery.id, createdAt: new Date().toISOString() }));
|
|
388
|
+
syncTree(stage);
|
|
389
|
+
syncDirectory(store.home);
|
|
390
|
+
try {
|
|
391
|
+
for (const name of names) {
|
|
392
|
+
const current = safePath(store.home, name);
|
|
393
|
+
if (existsSync(current)) { renameSync(current, path.join(previous, name)); moved.push(name); }
|
|
394
|
+
}
|
|
395
|
+
for (const name of ["artifacty.sqlite", "index.json", "artifacts"]) {
|
|
396
|
+
const source = path.join(stage, name);
|
|
397
|
+
if (existsSync(source)) { renameSync(source, path.join(store.home, name)); installed.push(name); }
|
|
398
|
+
}
|
|
399
|
+
syncDirectory(previous);
|
|
400
|
+
syncDirectory(store.home);
|
|
401
|
+
} catch (cause) {
|
|
402
|
+
try {
|
|
403
|
+
for (const name of installed.reverse()) rmSync(path.join(store.home, name), { recursive: true, force: true });
|
|
404
|
+
for (const name of moved.reverse()) renameSync(path.join(previous, name), path.join(store.home, name));
|
|
405
|
+
syncDirectory(store.home);
|
|
406
|
+
} catch (rollbackError) {
|
|
407
|
+
preserveStage = true;
|
|
408
|
+
throw error(`Restore and rollback failed. Preserve ${stage}; recovery snapshot ${recovery.id}. Original error: ${cause.message}; rollback error: ${rollbackError.message}`, "RESTORE_INCOMPLETE");
|
|
409
|
+
}
|
|
410
|
+
throw cause;
|
|
411
|
+
}
|
|
412
|
+
return { restored: id, recoveryBackupId: recovery.id };
|
|
413
|
+
} finally {
|
|
414
|
+
if (stage && !preserveStage && existsSync(stage)) rmSync(stage, { recursive: true, force: true });
|
|
415
|
+
release();
|
|
416
|
+
}
|
|
417
|
+
}
|
package/src/mcp-server.js
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
ARTIFACT_TYPES,
|
|
11
11
|
archiveArtifact,
|
|
12
12
|
authenticateApiToken,
|
|
13
|
+
canReadArtifactEvent,
|
|
13
14
|
createArtifact,
|
|
14
15
|
createStore,
|
|
15
16
|
getArtifact,
|
|
@@ -34,7 +35,7 @@ import {
|
|
|
34
35
|
import { convertAgentArtifact } from "./lib/converters.js";
|
|
35
36
|
import { createStructuredDiff, diffFormatFor, renderUnifiedDiffText, resolveDiffView } from "./lib/diff.js";
|
|
36
37
|
import { createEmbeddingProvider } from "./lib/embeddings.js";
|
|
37
|
-
import { EVENT_TYPES,
|
|
38
|
+
import { EVENT_TYPES, sanitizeEventForDelivery, subscribe } from "./lib/events.js";
|
|
38
39
|
import { inlineSchema } from "./lib/schemas.js";
|
|
39
40
|
import { effectiveScopes, requireScope } from "./lib/security.js";
|
|
40
41
|
import { resolvePublicBaseUrl } from "./lib/server-state.js";
|
|
@@ -896,6 +897,7 @@ async function callTool(context, name, args) {
|
|
|
896
897
|
...converted,
|
|
897
898
|
allowSecrets: args.allowSecrets,
|
|
898
899
|
auditAction: "import",
|
|
900
|
+
access: await mcpAccessContext(context),
|
|
899
901
|
audit: mcpAuditContext(context)
|
|
900
902
|
});
|
|
901
903
|
return toolResult({
|
|
@@ -1081,6 +1083,7 @@ async function callTool(context, name, args) {
|
|
|
1081
1083
|
activeWaits += 1;
|
|
1082
1084
|
try {
|
|
1083
1085
|
return toolResult(await waitForEvent({
|
|
1086
|
+
store: context.store,
|
|
1084
1087
|
artifactId: args.artifactId,
|
|
1085
1088
|
tag: args.tag,
|
|
1086
1089
|
type: args.type,
|
|
@@ -1294,6 +1297,7 @@ async function createNativeArtifact(context, args) {
|
|
|
1294
1297
|
allowSecrets: args.allowSecrets,
|
|
1295
1298
|
visibility: args.visibility,
|
|
1296
1299
|
relations: args.relations,
|
|
1300
|
+
access: await mcpAccessContext(context),
|
|
1297
1301
|
audit: mcpAuditContext(context)
|
|
1298
1302
|
});
|
|
1299
1303
|
}
|
|
@@ -1468,7 +1472,7 @@ function parseArtifactResourceUri(uri) {
|
|
|
1468
1472
|
// (stdio or one streamable-HTTP request) because it never outlives the
|
|
1469
1473
|
// single tool call: it subscribes, waits, and always unsubscribes before
|
|
1470
1474
|
// returning, regardless of whether a match arrived.
|
|
1471
|
-
function waitForEvent({ artifactId, tag, type, timeoutMs, access } = {}) {
|
|
1475
|
+
function waitForEvent({ store, artifactId, tag, type, timeoutMs, access } = {}) {
|
|
1472
1476
|
const filter = {};
|
|
1473
1477
|
if (artifactId) filter.artifactId = artifactId;
|
|
1474
1478
|
if (tag) filter.tag = tag;
|
|
@@ -1489,9 +1493,14 @@ function waitForEvent({ artifactId, tag, type, timeoutMs, access } = {}) {
|
|
|
1489
1493
|
// A matching event for a private artifact the caller cannot see must
|
|
1490
1494
|
// not resolve or leak the wait: keep waiting (silently) until a visible
|
|
1491
1495
|
// match arrives or the timeout fires.
|
|
1492
|
-
const unsubscribe = subscribe(filter, (event) => {
|
|
1496
|
+
const unsubscribe = subscribe(filter, async (event) => {
|
|
1497
|
+
if (settled) return;
|
|
1498
|
+
try {
|
|
1499
|
+
if (!await canReadArtifactEvent(store, event, access)) return;
|
|
1500
|
+
} catch {
|
|
1501
|
+
return;
|
|
1502
|
+
}
|
|
1493
1503
|
if (settled) return;
|
|
1494
|
-
if (!eventVisibleTo(event, access)) return;
|
|
1495
1504
|
settled = true;
|
|
1496
1505
|
clearTimeout(timer);
|
|
1497
1506
|
unsubscribe();
|
|
@@ -1529,10 +1538,13 @@ async function subscribeResource(context, uri) {
|
|
|
1529
1538
|
}
|
|
1530
1539
|
if (!context.subscriptions.has(uri)) {
|
|
1531
1540
|
const access = await mcpAccessContext(context);
|
|
1532
|
-
const unsubscribe = subscribe(filter, (event) => {
|
|
1533
|
-
|
|
1541
|
+
const unsubscribe = subscribe(filter, async (event) => {
|
|
1542
|
+
try {
|
|
1543
|
+
if (!await canReadArtifactEvent(context.store, event, access)) return;
|
|
1544
|
+
} catch {
|
|
1534
1545
|
return;
|
|
1535
1546
|
}
|
|
1547
|
+
if (!context.subscriptions.has(uri)) return;
|
|
1536
1548
|
context.notify({
|
|
1537
1549
|
jsonrpc: "2.0",
|
|
1538
1550
|
method: "notifications/resources/updated",
|