artifacty 0.10.8 → 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
@@ -258,6 +258,13 @@ ARTIFACTY_HOME=/path/to/shared/store artifacty serve
258
258
 
259
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.
260
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
+
261
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:
262
269
 
263
270
  ```bash
@@ -309,6 +316,9 @@ Browser routes:
309
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.
310
317
  - `/artifacts/:id/diff`: compare versions.
311
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.
312
322
  - `/api/audit`: list audit events.
313
323
 
314
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,9 @@ 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.
136
138
 
137
139
  For Linux user-service deployments, enable systemd lingering for the service
138
140
  account:
@@ -402,6 +402,10 @@ For background services, prefer a stable `ARTIFACTY_API_TOKEN` or `--api-token`
402
402
 
403
403
  ## Backup and Audit
404
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
+
405
409
  ```bash
406
410
  node src/cli.js audit --limit 20
407
411
  node src/cli.js backup
@@ -409,4 +413,8 @@ node src/cli.js export --file ./artifacty-backup.json
409
413
  node src/cli.js import-store --file ./artifacty-backup.json
410
414
  ```
411
415
 
412
- 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.8",
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) {
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
  }