artifacty 0.10.7 → 0.10.9

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 CHANGED
@@ -92,6 +92,10 @@ artifacty serve --host 10.0.0.50 --share-mode team --api-token "$ARTIFACTY_BOOTS
92
92
  artifacty install all --mcp-url http://10.0.0.50:8787/mcp --api-token "$ARTIFACTY_PERSONAL_TOKEN"
93
93
  ```
94
94
 
95
+ When running the central server as a Linux `systemctl --user` service, enable
96
+ lingering for the service account so Artifacty stays up after logout:
97
+ `sudo loginctl enable-linger $USER`.
98
+
95
99
  Administrators can create users individually at `/admin/users` or import them
96
100
  from CSV. Use `email,name,role,password,password_reset_required` headers. If
97
101
  `password` is empty, Artifacty generates a temporary password, shows it once in
@@ -254,6 +258,13 @@ ARTIFACTY_HOME=/path/to/shared/store artifacty serve
254
258
 
255
259
  Artifact metadata is stored in `artifacty.sqlite`; artifact content is stored as append-only version files under `artifacts/` for normal create and update flows. Administrators can repair or delete individual bad versions from the browser, and those exceptional actions are recorded in the audit log. The current browser server URL is written to `server.json` so MCP tools can return the correct links when the default port falls back. Existing `index.json` stores are migrated automatically on first access.
256
260
 
261
+ Administrators can download and restore artifact backups from `/admin/backup`.
262
+ The backup bundle contains artifact metadata and version contents, but not
263
+ users, sessions, API token records, or audit logs. Restoring a bundle replaces
264
+ the target server's artifact records and prunes unreferenced version files. For
265
+ large migrations or scripted server moves, use `artifacty backup` and
266
+ `artifacty import-store --file ./artifacty-backup.json`.
267
+
257
268
  Search uses a SQLite FTS5 index when the local Node SQLite build supports it. The index covers the latest version body plus title, tags, source agent, artifact type, format, and metadata summary. If FTS5 is unavailable, Artifacty keeps working with metadata search. Rebuild or check the store when needed:
258
269
 
259
270
  ```bash
@@ -305,6 +316,9 @@ Browser routes:
305
316
  - `/artifacts/:id/edit`: save a new version with Markdown, HTML, JSON, text, code, SVG, Mermaid, React, SARIF, CSV, image, or video syntax support. Browser edits that do not change the artifact are recorded as `update-noop` audit events without creating a version.
306
317
  - `/artifacts/:id/diff`: compare versions.
307
318
  - `/admin/artifacts/:id/versions`: administrator-only repair/delete screen for individual versions.
319
+ - `/admin/backup`: administrator-only artifact backup download and restore screen.
320
+ - `/api/admin/backup`: administrator-only artifact backup JSON download.
321
+ - `/api/admin/backup/import`: administrator-only artifact backup restore.
308
322
  - `/api/audit`: list audit events.
309
323
 
310
324
  List APIs support pagination with `limit` and `offset`. Responses keep the top-level `artifacts` array and include `pagination` and `search` metadata:
@@ -132,7 +132,22 @@ the user's email as `actor` and created artifacts record the same email as
132
132
  Production-like internal deployments should run Artifacty behind a TLS reverse
133
133
  proxy, keep `ARTIFACTY_ENABLE_REACT_RENDERER` disabled unless the team trusts
134
134
  all artifact authors, and store `ARTIFACTY_HOME` on local server disk with
135
- regular backups.
135
+ regular backups. Administrators can export and restore artifact backup bundles
136
+ from `/admin/backup`; large migrations can use `artifacty backup` on the old
137
+ server and `artifacty import-store --file <backup.json>` on the new server.
138
+
139
+ For Linux user-service deployments, enable systemd lingering for the service
140
+ account:
141
+
142
+ ```bash
143
+ sudo loginctl enable-linger artifacty
144
+ loginctl show-user artifacty -p Linger
145
+ ```
146
+
147
+ Use the actual service account name. Without lingering, `systemctl --user`
148
+ services are tied to the user's login manager and can stop after logout, making
149
+ the central dashboard and `/mcp` endpoint unavailable until that user logs in
150
+ again. A system-level unit is another valid option for central servers.
136
151
 
137
152
  The central server exposes `/mcp` only when explicitly enabled. This keeps the
138
153
  local browser/API server behavior unchanged while making team MCP exposure an
@@ -393,13 +393,19 @@ The generated service runs `src/server.js` with explicit `--host` and `--home` a
393
393
  Activation is still delegated to the OS service manager:
394
394
 
395
395
  - macOS: `launchctl load ~/Library/LaunchAgents/com.artifacty.server.plist`
396
- - Linux: `systemctl --user enable --now com.artifacty.server.service`
396
+ - Linux: `sudo loginctl enable-linger $USER`, then `systemctl --user enable --now com.artifacty.server.service`
397
397
  - Windows: run the generated PowerShell script, which registers and starts the `ArtifactyServer` scheduled task.
398
398
 
399
+ On Linux central servers, user services stop when the user's systemd manager exits unless lingering is enabled. Verify durable operation with `loginctl show-user $USER -p Linger`; it should report `Linger=yes`. Without lingering, Artifacty may be unavailable after logout and appear to recover only after the user logs back in.
400
+
399
401
  For background services, prefer a stable `ARTIFACTY_API_TOKEN` or `--api-token` value. `serve --generate-token` is intended for temporary interactive sessions; the parent CLI returns the generated token in JSON.
400
402
 
401
403
  ## Backup and Audit
402
404
 
405
+ Administrators can open `/admin/backup` to download an artifact backup JSON file
406
+ or restore a backup into the current server. The browser restore flow is useful
407
+ for small and medium migrations; use the CLI for large bundles.
408
+
403
409
  ```bash
404
410
  node src/cli.js audit --limit 20
405
411
  node src/cli.js backup
@@ -407,4 +413,8 @@ node src/cli.js export --file ./artifacty-backup.json
407
413
  node src/cli.js import-store --file ./artifacty-backup.json
408
414
  ```
409
415
 
410
- Backups include SQLite metadata plus immutable version file contents in one JSON bundle. Importing a store replaces the target store index, so run it against a new or intentionally chosen `ARTIFACTY_HOME`.
416
+ Backups include artifact metadata plus immutable version file contents in one
417
+ JSON bundle. They do not include users, sessions, API token records, or audit
418
+ logs. Importing a store replaces the target artifact records and prunes
419
+ unreferenced version files, so run it against a new or intentionally chosen
420
+ `ARTIFACTY_HOME`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "artifacty",
3
- "version": "0.10.7",
3
+ "version": "0.10.9",
4
4
  "description": "Local artifact exchange for heterogeneous LLM agents via HTTP and MCP.",
5
5
  "type": "module",
6
6
  "keywords": [
package/src/lib/backup.js CHANGED
@@ -1,11 +1,29 @@
1
- import { mkdir, readFile, writeFile } from "node:fs/promises";
1
+ import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { createStore, loadIndex, writeIndex } from "./storage.js";
4
4
 
5
+ export const MAX_BACKUP_BYTES = 128 * 1024 * 1024;
6
+
5
7
  export async function exportStore(store = createStore(), outputPath) {
6
8
  if (!outputPath) {
7
9
  throw new Error("export requires --file <path>");
8
10
  }
11
+ const bundle = await buildStoreBackup(store);
12
+ await mkdir(path.dirname(path.resolve(outputPath)), { recursive: true });
13
+ await writeFile(outputPath, `${JSON.stringify(bundle, null, 2)}\n`, "utf8");
14
+ return {
15
+ path: path.resolve(outputPath),
16
+ artifactCount: bundle.artifacts.length,
17
+ exportedAt: bundle.exportedAt
18
+ };
19
+ }
20
+
21
+ export async function exportStoreToString(store = createStore()) {
22
+ const bundle = await buildStoreBackup(store);
23
+ return `${JSON.stringify(bundle, null, 2)}\n`;
24
+ }
25
+
26
+ export async function buildStoreBackup(store = createStore()) {
9
27
  const index = await loadIndex(store);
10
28
  const artifacts = [];
11
29
  for (const artifact of index.artifacts) {
@@ -13,24 +31,18 @@ export async function exportStore(store = createStore(), outputPath) {
13
31
  for (const version of artifact.versions) {
14
32
  versions.push({
15
33
  ...version,
34
+ path: normalizeBackupRelativePath(version.path),
16
35
  content: await readFile(path.join(store.home, version.path), "utf8")
17
36
  });
18
37
  }
19
38
  artifacts.push({ ...artifact, versions });
20
39
  }
21
40
 
22
- const bundle = {
41
+ return {
23
42
  schemaVersion: 1,
24
43
  exportedAt: new Date().toISOString(),
25
44
  artifacts
26
45
  };
27
- await mkdir(path.dirname(path.resolve(outputPath)), { recursive: true });
28
- await writeFile(outputPath, `${JSON.stringify(bundle, null, 2)}\n`, "utf8");
29
- return {
30
- path: path.resolve(outputPath),
31
- artifactCount: artifacts.length,
32
- exportedAt: bundle.exportedAt
33
- };
34
46
  }
35
47
 
36
48
  export async function importStore(store = createStore(), inputPath) {
@@ -38,8 +50,41 @@ export async function importStore(store = createStore(), inputPath) {
38
50
  throw new Error("import-store requires --file <path>");
39
51
  }
40
52
  const bundle = JSON.parse(await readFile(inputPath, "utf8"));
53
+ const result = await importStoreBundle(store, bundle);
54
+ return {
55
+ path: path.resolve(inputPath),
56
+ ...result
57
+ };
58
+ }
59
+
60
+ export async function importStoreFromString(store = createStore(), content) {
61
+ if (!String(content || "").trim()) {
62
+ throw new Error("Artifacty backup JSON is required");
63
+ }
64
+ let bundle;
65
+ try {
66
+ bundle = JSON.parse(content);
67
+ } catch (error) {
68
+ throw Object.assign(new Error(`Invalid Artifacty backup JSON: ${error.message}`), {
69
+ statusCode: 400,
70
+ code: "INVALID_BACKUP_JSON"
71
+ });
72
+ }
73
+ return importStoreBundle(store, bundle);
74
+ }
75
+
76
+ export async function importStoreBundle(store = createStore(), bundle) {
77
+ if (!bundle || typeof bundle !== "object") {
78
+ throw Object.assign(new Error("Invalid Artifacty backup: JSON object expected"), {
79
+ statusCode: 400,
80
+ code: "INVALID_BACKUP"
81
+ });
82
+ }
41
83
  if (!Array.isArray(bundle.artifacts)) {
42
- throw new Error("Invalid Artifacty backup: artifacts array missing");
84
+ throw Object.assign(new Error("Invalid Artifacty backup: artifacts array missing"), {
85
+ statusCode: 400,
86
+ code: "INVALID_BACKUP"
87
+ });
43
88
  }
44
89
 
45
90
  const index = {
@@ -52,8 +97,9 @@ export async function importStore(store = createStore(), inputPath) {
52
97
  for (const version of artifact.versions || []) {
53
98
  const cleanVersion = { ...version };
54
99
  delete cleanVersion.content;
100
+ cleanVersion.path = normalizeBackupRelativePath(cleanVersion.path);
55
101
  const content = version.content || "";
56
- const absolutePath = path.join(store.home, cleanVersion.path);
102
+ const absolutePath = backupVersionPath(store, cleanVersion.path);
57
103
  await mkdir(path.dirname(absolutePath), { recursive: true });
58
104
  await writeFile(absolutePath, content, "utf8");
59
105
  versions.push(cleanVersion);
@@ -62,8 +108,8 @@ export async function importStore(store = createStore(), inputPath) {
62
108
  }
63
109
 
64
110
  await writeIndex(store, index);
111
+ await pruneUnreferencedArtifactFiles(store, index);
65
112
  return {
66
- path: path.resolve(inputPath),
67
113
  artifactCount: index.artifacts.length,
68
114
  importedAt: new Date().toISOString()
69
115
  };
@@ -73,3 +119,84 @@ export function defaultBackupPath(store = createStore()) {
73
119
  const stamp = new Date().toISOString().replace(/[:.]/g, "-");
74
120
  return path.join(store.home, "backups", `artifacty-${stamp}.json`);
75
121
  }
122
+
123
+ function backupVersionPath(store, relativePath) {
124
+ const normalized = normalizeBackupRelativePath(relativePath);
125
+ const root = path.resolve(store.home);
126
+ const absolute = path.resolve(root, ...normalized.split("/"));
127
+ if (absolute !== root && !absolute.startsWith(`${root}${path.sep}`)) {
128
+ throw Object.assign(new Error(`Invalid Artifacty backup version path: ${relativePath}`), {
129
+ statusCode: 400,
130
+ code: "INVALID_BACKUP_PATH"
131
+ });
132
+ }
133
+ return absolute;
134
+ }
135
+
136
+ function normalizeBackupRelativePath(relativePath) {
137
+ const value = String(relativePath || "");
138
+ if (!value || path.isAbsolute(value)) {
139
+ throw Object.assign(new Error(`Invalid Artifacty backup version path: ${value}`), {
140
+ statusCode: 400,
141
+ code: "INVALID_BACKUP_PATH"
142
+ });
143
+ }
144
+
145
+ const portable = value.replaceAll("\\", "/");
146
+ if (/^[A-Za-z]:\//.test(portable) || portable.startsWith("//")) {
147
+ throw Object.assign(new Error(`Invalid Artifacty backup version path: ${value}`), {
148
+ statusCode: 400,
149
+ code: "INVALID_BACKUP_PATH"
150
+ });
151
+ }
152
+
153
+ const parts = portable
154
+ .split("/")
155
+ .filter((part) => part && part !== ".");
156
+ if (parts.length === 0 || parts.some((part) => part === "..")) {
157
+ throw Object.assign(new Error(`Invalid Artifacty backup version path: ${value}`), {
158
+ statusCode: 400,
159
+ code: "INVALID_BACKUP_PATH"
160
+ });
161
+ }
162
+ return parts.join("/");
163
+ }
164
+
165
+ async function pruneUnreferencedArtifactFiles(store, index) {
166
+ const referenced = new Set();
167
+ for (const artifact of index.artifacts) {
168
+ for (const version of artifact.versions || []) {
169
+ referenced.add(backupVersionPath(store, version.path));
170
+ }
171
+ }
172
+
173
+ const files = await listFiles(store.artifactsDir);
174
+ for (const filePath of files) {
175
+ if (!referenced.has(filePath)) {
176
+ await rm(filePath, { force: true });
177
+ }
178
+ }
179
+ }
180
+
181
+ async function listFiles(root) {
182
+ let entries;
183
+ try {
184
+ entries = await readdir(root, { withFileTypes: true });
185
+ } catch (error) {
186
+ if (error.code === "ENOENT") {
187
+ return [];
188
+ }
189
+ throw error;
190
+ }
191
+
192
+ const files = [];
193
+ for (const entry of entries) {
194
+ const fullPath = path.join(root, entry.name);
195
+ if (entry.isDirectory()) {
196
+ files.push(...await listFiles(fullPath));
197
+ } else if (entry.isFile()) {
198
+ files.push(path.resolve(fullPath));
199
+ }
200
+ }
201
+ return files;
202
+ }
package/src/lib/render.js CHANGED
@@ -399,7 +399,7 @@ export function renderAccountPage({ baseUrl, user, tokens = [], createdToken = "
399
399
  </div>
400
400
  <nav>
401
401
  <a href="${view.href("/")}">${view.text("nav.index")}</a>
402
- ${user.role === "admin" ? `<a href="/admin/users">Users</a>` : ""}
402
+ ${user.role === "admin" ? `<a href="/admin/users">Users</a><a href="/admin/backup">Backup</a>` : ""}
403
403
  <a href="/account/password">Password</a>
404
404
  <form class="nav-form" method="post" action="/logout"><button type="submit">Sign out</button></form>
405
405
  ${languageSwitcher(view)}
@@ -483,6 +483,61 @@ export function renderPasswordPage({ baseUrl, user, required = false, error = ""
483
483
  });
484
484
  }
485
485
 
486
+ export function renderAdminBackupPage({ baseUrl, user, integrity, importResult = null, importError = "", locale = DEFAULT_LOCALE, currentPath = "/admin/backup" }) {
487
+ const view = viewContext(locale, currentPath);
488
+ return pageShell({
489
+ title: "Backup",
490
+ body: `
491
+ <header class="topbar">
492
+ <div>
493
+ <h1>Backup</h1>
494
+ <p>${escapeHtml(baseUrl)}</p>
495
+ </div>
496
+ <nav>
497
+ <a href="${view.href("/")}">${view.text("nav.index")}</a>
498
+ <a href="/admin/users">Users</a>
499
+ <a href="/account">Account</a>
500
+ ${languageSwitcher(view)}
501
+ </nav>
502
+ </header>
503
+ <main class="artifact-view">
504
+ ${importResult ? `<p class="auth-success">Restore complete. ${escapeHtml(importResult.artifactCount)} artifacts imported.</p>` : ""}
505
+ ${importError ? `<p class="auth-error">${escapeHtml(importError)}</p>` : ""}
506
+ <section class="meta-card">
507
+ <h2>Store status</h2>
508
+ <table class="data-table">
509
+ <tbody>
510
+ <tr><th>Home</th><td>${escapeHtml(integrity.store)}</td></tr>
511
+ <tr><th>Artifacts</th><td>${escapeHtml(integrity.artifactCount)}</td></tr>
512
+ <tr><th>Versions</th><td>${escapeHtml(integrity.versionCount)}</td></tr>
513
+ <tr><th>Content bytes</th><td>${escapeHtml(integrity.totalBytes)}</td></tr>
514
+ <tr><th>Integrity</th><td>${integrity.ok ? "OK" : "Needs attention"}</td></tr>
515
+ </tbody>
516
+ </table>
517
+ ${integrity.ok ? "" : `<p class="auth-warning">Run <code>artifacty integrity</code> before migration. Current backup will only include readable referenced version files.</p>`}
518
+ </section>
519
+ <section class="meta-card">
520
+ <h2>Download artifact backup</h2>
521
+ <p class="muted">Exports artifact metadata and version contents as one JSON bundle. Users, sessions, and API token records are not included.</p>
522
+ <p><a class="button-link" href="/admin/backup/export">Download backup JSON</a></p>
523
+ </section>
524
+ <section class="meta-card">
525
+ <h2>Restore artifact backup</h2>
526
+ <p class="auth-warning">Restore replaces the target server's artifact records and version files with the backup contents. Existing users, sessions, API tokens, and audit logs stay unchanged.</p>
527
+ <form class="editor-form" method="post" action="/admin/backup/import">
528
+ <label class="field content-field">
529
+ <span>Backup JSON</span>
530
+ <textarea class="compact-textarea" name="backup" spellcheck="false" placeholder="{&quot;schemaVersion&quot;:1,&quot;artifacts&quot;:[...]}" required></textarea>
531
+ </label>
532
+ <footer class="editor-actions"><button type="submit">Restore backup</button></footer>
533
+ </form>
534
+ </section>
535
+ </main>
536
+ `,
537
+ locale: view.locale
538
+ });
539
+ }
540
+
486
541
  export function renderAdminUsersPage({ baseUrl, user, users = [], importResult = null, importError = "", locale = DEFAULT_LOCALE, currentPath = "/admin/users" }) {
487
542
  const view = viewContext(locale, currentPath);
488
543
  const rows = users.map((item) => `
@@ -542,6 +597,7 @@ export function renderAdminUsersPage({ baseUrl, user, users = [], importResult =
542
597
  </div>
543
598
  <nav>
544
599
  <a href="${view.href("/")}">${view.text("nav.index")}</a>
600
+ <a href="/admin/backup">Backup</a>
545
601
  <a href="/account">Account</a>
546
602
  ${languageSwitcher(view)}
547
603
  </nav>
@@ -1361,6 +1417,24 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
1361
1417
  transition: background 0.15s ease, border-color 0.15s ease;
1362
1418
  }
1363
1419
  button:hover { background: var(--accent-2); border-color: var(--accent-2); }
1420
+ .button-link {
1421
+ display: inline-flex;
1422
+ min-height: 38px;
1423
+ align-items: center;
1424
+ justify-content: center;
1425
+ padding: 7px 15px;
1426
+ border: 1px solid var(--accent);
1427
+ border-radius: 8px;
1428
+ background: var(--accent);
1429
+ color: var(--accent-ink);
1430
+ font-weight: 600;
1431
+ }
1432
+ .button-link:hover {
1433
+ border-color: var(--accent-2);
1434
+ background: var(--accent-2);
1435
+ color: var(--accent-ink);
1436
+ text-decoration: none;
1437
+ }
1364
1438
  .inline-action button {
1365
1439
  min-height: 32px;
1366
1440
  padding: 5px 12px;
@@ -2043,7 +2117,7 @@ function authNav(user) {
2043
2117
  if (!user) {
2044
2118
  return `<a href="/login">Sign in</a>`;
2045
2119
  }
2046
- return `${user.role === "admin" ? `<a href="/admin/users">Users</a>` : ""}<a href="/account">Account</a>`;
2120
+ return `${user.role === "admin" ? `<a href="/admin/users">Users</a><a href="/admin/backup">Backup</a>` : ""}<a href="/account">Account</a>`;
2047
2121
  }
2048
2122
 
2049
2123
  export function escapeHtml(value) {
@@ -184,9 +184,11 @@ function definitionForPlatform(platform, options = {}, renderOnly) {
184
184
  path: servicePath(platform, options),
185
185
  content: createSystemdUserUnit(options),
186
186
  nextSteps: [
187
+ "sudo loginctl enable-linger $USER",
187
188
  "systemctl --user daemon-reload",
188
189
  `systemctl --user enable --now ${path.basename(servicePath(platform, options))}`,
189
190
  `systemctl --user status ${path.basename(servicePath(platform, options))}`,
191
+ "loginctl show-user $USER -p Linger",
190
192
  `journalctl --user -u ${path.basename(servicePath(platform, options))} -f`,
191
193
  `systemctl --user disable --now ${path.basename(servicePath(platform, options))}`
192
194
  ],
@@ -10,6 +10,7 @@ import { assertNoSecrets, securityConfig } from "./security.js";
10
10
  export const STORE_VERSION = 4;
11
11
  export const ARTIFACT_SCHEMA_VERSION = 1;
12
12
  export const MAX_ARTIFACT_BYTES = 16 * 1024 * 1024;
13
+ const SOURCE_AGENT_NORMALIZATION_VERSION = "1";
13
14
  export const USER_ROLES = ["admin", "user"];
14
15
  export const ARTIFACT_FORMATS = [
15
16
  "html",
@@ -123,6 +124,11 @@ export async function writeIndex(store, index) {
123
124
  insertVersionRecord(db, artifact.id, version);
124
125
  }
125
126
  }
127
+ normalizeStoredSourceAgents(db, store, {
128
+ force: true,
129
+ inTransaction: true,
130
+ rebuildSearch: false
131
+ });
126
132
  rebuildSearchIndexInDb(db, store);
127
133
  });
128
134
  } finally {
@@ -1502,9 +1508,26 @@ function migrateJsonIndex(db, store) {
1502
1508
  });
1503
1509
  }
1504
1510
 
1511
+ function metaValue(db, key) {
1512
+ return db.prepare("SELECT value FROM meta WHERE key = ?").get(key)?.value || "";
1513
+ }
1514
+
1505
1515
  const INFERABLE_SOURCE_AGENTS = new Set(["claude", "codex", "copilot", "cursor", "gemini"]);
1506
1516
 
1507
- function normalizeStoredSourceAgents(db, store) {
1517
+ function normalizeStoredSourceAgents(db, store, options = {}) {
1518
+ if (!options.force && metaValue(db, "source_agent_normalized_version") === SOURCE_AGENT_NORMALIZATION_VERSION) {
1519
+ return;
1520
+ }
1521
+
1522
+ const run = () => normalizeStoredSourceAgentsInDb(db, store, options);
1523
+ if (options.inTransaction) {
1524
+ run();
1525
+ } else {
1526
+ transaction(db, run);
1527
+ }
1528
+ }
1529
+
1530
+ function normalizeStoredSourceAgentsInDb(db, store, options = {}) {
1508
1531
  const rows = db.prepare(`
1509
1532
  SELECT id, source_agent, tags_json
1510
1533
  FROM artifacts
@@ -1512,37 +1535,37 @@ function normalizeStoredSourceAgents(db, store) {
1512
1535
  const updateArtifact = db.prepare("UPDATE artifacts SET source_agent = ?, tags_json = ? WHERE id = ?");
1513
1536
  let changed = false;
1514
1537
 
1515
- transaction(db, () => {
1516
- for (const row of rows) {
1517
- const normalized = normalizeSourceAgent(row.source_agent, { defaultValue: "unknown" });
1518
- const inferred = isUnknownSourceAgent(normalized)
1519
- ? inferStoredSourceAgent(db, row)
1520
- : normalized;
1521
- const nextSourceAgent = inferred || normalized;
1522
- const nextTags = normalizeStoredSourceTags(row.tags_json, nextSourceAgent);
1523
- if (nextSourceAgent !== row.source_agent || nextTags !== row.tags_json) {
1524
- updateArtifact.run(nextSourceAgent, nextTags, row.id);
1525
- changed = true;
1526
- }
1538
+ for (const row of rows) {
1539
+ const normalized = normalizeSourceAgent(row.source_agent, { defaultValue: "unknown" });
1540
+ const inferred = isUnknownSourceAgent(normalized)
1541
+ ? inferStoredSourceAgent(db, row)
1542
+ : normalized;
1543
+ const nextSourceAgent = inferred || normalized;
1544
+ const nextTags = normalizeStoredSourceTags(row.tags_json, nextSourceAgent);
1545
+ if (nextSourceAgent !== row.source_agent || nextTags !== row.tags_json) {
1546
+ updateArtifact.run(nextSourceAgent, nextTags, row.id);
1547
+ changed = true;
1527
1548
  }
1549
+ }
1528
1550
 
1529
- const auditRows = db.prepare(`
1530
- SELECT id, source_agent
1531
- FROM audit_log
1532
- WHERE source_agent IS NOT NULL
1533
- AND TRIM(source_agent) != ''
1534
- `).all();
1535
- const updateAudit = db.prepare("UPDATE audit_log SET source_agent = ? WHERE id = ?");
1536
- for (const row of auditRows) {
1537
- const normalized = normalizeSourceAgent(row.source_agent, { defaultValue: "" });
1538
- if (normalized && normalized !== row.source_agent) {
1539
- updateAudit.run(normalized, row.id);
1540
- changed = true;
1541
- }
1551
+ const auditRows = db.prepare(`
1552
+ SELECT id, source_agent
1553
+ FROM audit_log
1554
+ WHERE source_agent IS NOT NULL
1555
+ AND TRIM(source_agent) != ''
1556
+ `).all();
1557
+ const updateAudit = db.prepare("UPDATE audit_log SET source_agent = ? WHERE id = ?");
1558
+ for (const row of auditRows) {
1559
+ const normalized = normalizeSourceAgent(row.source_agent, { defaultValue: "" });
1560
+ if (normalized && normalized !== row.source_agent) {
1561
+ updateAudit.run(normalized, row.id);
1562
+ changed = true;
1542
1563
  }
1543
- });
1564
+ }
1565
+
1566
+ db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('source_agent_normalized_version', ?)").run(SOURCE_AGENT_NORMALIZATION_VERSION);
1544
1567
 
1545
- if (changed) {
1568
+ if (changed && options.rebuildSearch !== false) {
1546
1569
  rebuildSearchIndexInDb(db, store);
1547
1570
  }
1548
1571
  }
package/src/server.js CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  archiveArtifact,
9
9
  authenticateApiToken,
10
10
  changeUserPassword,
11
+ checkStoreIntegrity,
11
12
  countUsers,
12
13
  createApiToken,
13
14
  createArtifact,
@@ -31,6 +32,7 @@ import {
31
32
  updateArtifact,
32
33
  verifyUserPassword
33
34
  } from "./lib/storage.js";
35
+ import { MAX_BACKUP_BYTES, buildStoreBackup, exportStoreToString, importStoreBundle, importStoreFromString } from "./lib/backup.js";
34
36
  import { convertAgentArtifact } from "./lib/converters.js";
35
37
  import { createLineDiff } from "./lib/diff.js";
36
38
  import { EDITOR_CLIENT_PATH, VIEWER_CLIENT_PATH, editorClientFilePath, editorVendorPath, viewerClientFilePath } from "./lib/editor-assets.js";
@@ -43,6 +45,7 @@ import {
43
45
  renderArtifactFormPage,
44
46
  renderArtifactPage,
45
47
  renderAccountPage,
48
+ renderAdminBackupPage,
46
49
  renderAdminArtifactVersionsPage,
47
50
  renderAdminUsersPage,
48
51
  renderPasswordPage,
@@ -334,6 +337,60 @@ export async function handleRequest({ request, response, store, host, port, secu
334
337
  return sendRedirect(response, "/account");
335
338
  }
336
339
 
340
+ if (method === "GET" && pathname === "/admin/backup") {
341
+ if (!currentUser) {
342
+ return sendRedirect(response, "/login");
343
+ }
344
+ requireAdmin(currentUser);
345
+ return sendHtml(response, renderAdminBackupPage({
346
+ baseUrl,
347
+ user: currentUser,
348
+ integrity: await checkStoreIntegrity(store),
349
+ locale,
350
+ currentPath
351
+ }), 200, headOnly);
352
+ }
353
+
354
+ if (method === "GET" && pathname === "/admin/backup/export") {
355
+ if (!currentUser) {
356
+ return sendRedirect(response, "/login");
357
+ }
358
+ requireAdmin(currentUser);
359
+ return sendBackupDownload(response, await exportStoreToString(store), headOnly);
360
+ }
361
+
362
+ if (method === "POST" && pathname === "/admin/backup/import") {
363
+ assertLocalOrigin(request);
364
+ if (!currentUser) {
365
+ return sendRedirect(response, "/login");
366
+ }
367
+ requireAdmin(currentUser);
368
+ const body = await readFormBody(request, MAX_BACKUP_BYTES);
369
+ try {
370
+ const importResult = await importStoreFromString(store, body.backup || "");
371
+ return sendHtml(response, renderAdminBackupPage({
372
+ baseUrl,
373
+ user: currentUser,
374
+ integrity: await checkStoreIntegrity(store),
375
+ importResult,
376
+ locale,
377
+ currentPath: "/admin/backup"
378
+ }));
379
+ } catch (error) {
380
+ if ((error.statusCode || 500) >= 500) {
381
+ throw error;
382
+ }
383
+ return sendHtml(response, renderAdminBackupPage({
384
+ baseUrl,
385
+ user: currentUser,
386
+ integrity: await checkStoreIntegrity(store),
387
+ importError: error.message,
388
+ locale,
389
+ currentPath: "/admin/backup"
390
+ }), error.statusCode || 400);
391
+ }
392
+ }
393
+
337
394
  if (method === "GET" && pathname === "/admin/users") {
338
395
  if (!currentUser) {
339
396
  return sendRedirect(response, "/login");
@@ -588,6 +645,23 @@ export async function handleRequest({ request, response, store, host, port, secu
588
645
  return sendJson(response, { events }, 200, headOnly);
589
646
  }
590
647
 
648
+ if (method === "GET" && pathname === "/api/admin/backup") {
649
+ requireAdminAuth(request.artifactyAuth);
650
+ return sendJson(response, await buildStoreBackup(store), 200, headOnly, {
651
+ "content-disposition": `attachment; filename="${backupFileName()}"`
652
+ });
653
+ }
654
+
655
+ if (method === "POST" && pathname === "/api/admin/backup/import") {
656
+ assertLocalOrigin(request);
657
+ requireAdminAuth(request.artifactyAuth);
658
+ const body = await readJsonBody(request, MAX_BACKUP_BYTES);
659
+ const result = typeof body.backup === "string"
660
+ ? await importStoreFromString(store, body.backup)
661
+ : await importStoreBundle(store, Array.isArray(body.artifacts) ? body : body.backup);
662
+ return sendJson(response, result);
663
+ }
664
+
591
665
  if (method === "POST" && pathname === "/api/artifacts") {
592
666
  assertLocalOrigin(request);
593
667
  const body = await readJsonBody(request);
@@ -835,8 +909,8 @@ function paginationJson(page) {
835
909
  };
836
910
  }
837
911
 
838
- export async function readJsonBody(request) {
839
- const raw = await readBody(request, MAX_ARTIFACT_BYTES + 1024);
912
+ export async function readJsonBody(request, limitBytes = MAX_ARTIFACT_BYTES + 1024) {
913
+ const raw = await readBody(request, limitBytes);
840
914
  if (!raw.trim()) {
841
915
  return {};
842
916
  }
@@ -850,8 +924,8 @@ export async function readJsonBody(request) {
850
924
  }
851
925
  }
852
926
 
853
- export async function readFormBody(request) {
854
- const raw = await readBody(request, MAX_ARTIFACT_BYTES + 1024);
927
+ export async function readFormBody(request, limitBytes = MAX_ARTIFACT_BYTES + 1024) {
928
+ const raw = await readBody(request, limitBytes);
855
929
  const params = new URLSearchParams(raw);
856
930
  return Object.fromEntries(params.entries());
857
931
  }
@@ -903,15 +977,30 @@ export function assertLocalOrigin(request) {
903
977
  }
904
978
  }
905
979
 
906
- export function sendJson(response, data, statusCode = 200, headOnly = false) {
980
+ export function sendJson(response, data, statusCode = 200, headOnly = false, headers = {}) {
907
981
  response.writeHead(statusCode, {
908
982
  "content-type": "application/json; charset=utf-8",
909
983
  "cache-control": "no-store",
910
- "x-content-type-options": "nosniff"
984
+ "x-content-type-options": "nosniff",
985
+ ...headers
911
986
  });
912
987
  response.end(headOnly ? undefined : `${JSON.stringify(data, null, 2)}\n`);
913
988
  }
914
989
 
990
+ function sendBackupDownload(response, content, headOnly = false) {
991
+ response.writeHead(200, {
992
+ "content-type": "application/json; charset=utf-8",
993
+ "content-disposition": `attachment; filename="${backupFileName()}"`,
994
+ "cache-control": "no-store",
995
+ "x-content-type-options": "nosniff"
996
+ });
997
+ response.end(headOnly ? undefined : content);
998
+ }
999
+
1000
+ function backupFileName() {
1001
+ return `artifacty-${new Date().toISOString().replace(/[:.]/g, "-")}.json`;
1002
+ }
1003
+
915
1004
  export function sendHtml(response, html, statusCode = 200, headOnly = false, contentSecurityPolicy = defaultContentSecurityPolicy()) {
916
1005
  response.writeHead(statusCode, {
917
1006
  "content-type": "text/html; charset=utf-8",
@@ -1100,6 +1189,15 @@ function requireAdmin(user) {
1100
1189
  }
1101
1190
  }
1102
1191
 
1192
+ function requireAdminAuth(auth) {
1193
+ if (auth?.role !== "admin" && auth?.user?.role !== "admin") {
1194
+ throw Object.assign(new Error("Artifacty admin privileges required"), {
1195
+ code: "ADMIN_REQUIRED",
1196
+ statusCode: 403
1197
+ });
1198
+ }
1199
+ }
1200
+
1103
1201
  async function sessionUserFromRequest(store, request) {
1104
1202
  return getSessionUser(store, sessionTokenFromRequest(request));
1105
1203
  }