artifacty 0.10.5 → 0.10.6

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
@@ -252,7 +252,7 @@ By default Artifacty stores files under `~/.artifacty`.
252
252
  ARTIFACTY_HOME=/path/to/shared/store artifacty serve
253
253
  ```
254
254
 
255
- Artifact metadata is stored in `artifacty.sqlite`; artifact content is stored as immutable version files under `artifacts/`. 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.
255
+ 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
256
 
257
257
  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
258
 
@@ -302,8 +302,9 @@ Browser routes:
302
302
  - `/`: list artifacts with search, tag, and source filters.
303
303
  - `/new`: create an Artifacty-native artifact with the CodeMirror editor.
304
304
  - `/import`: paste an external agent artifact and convert it with automatic editor mode detection.
305
- - `/artifacts/:id/edit`: save a new version with Markdown, HTML, JSON, text, code, SVG, Mermaid, React, SARIF, CSV, image, or video syntax support.
305
+ - `/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
306
  - `/artifacts/:id/diff`: compare versions.
307
+ - `/admin/artifacts/:id/versions`: administrator-only repair/delete screen for individual versions.
307
308
  - `/api/audit`: list audit events.
308
309
 
309
310
  List APIs support pagination with `limit` and `offset`. Responses keep the top-level `artifacts` array and include `pagination` and `search` metadata:
@@ -320,7 +321,7 @@ The browser UI defaults to English. Add `?lang=ko` to any browser route to use K
320
321
  Schema and storage:
321
322
 
322
323
  - Metadata lives in SQLite with `schemaVersion: 1`, `artifactType`, `publisherId`, and `archivedAt`.
323
- - Archive hides artifacts from default lists without deleting versions.
324
+ - Archive hides artifacts from default lists without deleting versions. Admin version repair/delete is available for correcting accidental or sensitive historical versions and records `version-repair` or `version-delete` audit events.
324
325
  - Bundle artifacts store multiple files or base64 assets as portable JSON.
325
326
  - Supported formats are `html`, `markdown`, `text`, `json`, `code`, `svg`, `mermaid`, `react`, `sarif`, `csv`, `image`, and `video`.
326
327
  - Native create/import paths infer `html` from HTML documents or fragments when no explicit format is supplied.
@@ -341,7 +342,7 @@ Schema and storage:
341
342
  - Non-local sharing is intended for trusted LAN or VPN sessions. Prefer a specific interface IP over `0.0.0.0`, keep React rendering disabled, and see [docs/network-sharing.md](docs/network-sharing.md).
342
343
  - Non-local binding prints a startup warning because Artifacty does not terminate TLS.
343
344
  - Artifact content is scanned for common API keys and private keys before storage. Use `--allow-secrets` or `ARTIFACTY_ALLOW_SECRETS=true` only for intentional exceptions.
344
- - Creates, updates, reads, imports, archives, and restores write audit events to SQLite. Legacy artifacts without a stored publisher are best-effort backfilled from their first `create` or `import` audit actor.
345
+ - Creates, updates, reads, imports, archives, restores, no-op browser edits, and admin version repair/delete actions write audit events to SQLite. Legacy artifacts without a stored publisher are best-effort backfilled from their first `create` or `import` audit actor.
345
346
  - CodeMirror editor/viewer and renderer assets are served from local npm dependencies through a package allowlist, not from a public CDN. JavaScript asset routes answer `Origin: null` requests with `Access-Control-Allow-Origin: null` so sandboxed renderer iframes can import local ESM without `allow-same-origin`.
346
347
  - Mutating HTTP routes reject non-local browser origins.
347
348
  - HTML artifacts render in a sandboxed iframe.
@@ -53,7 +53,10 @@ best-effort backfilled from the first `create` or `import` audit actor.
53
53
 
54
54
  ## Version Record
55
55
 
56
- Each version is immutable and points at one content file.
56
+ Normal create and update flows append versions, and each version points at one
57
+ content file. Administrator repair/delete actions are exceptional maintenance
58
+ operations for correcting accidental or sensitive historical versions; they
59
+ write `version-repair` or `version-delete` audit events.
57
60
 
58
61
  ```json
59
62
  {
@@ -115,7 +118,7 @@ Metadata is free-form JSON, but converter-generated metadata uses these keys:
115
118
 
116
119
  ## Archive Semantics
117
120
 
118
- Artifacts are not deleted by P0 behavior. Archive sets `archivedAt` and hides the artifact from default list results. `includeArchived=true` includes archived records. Restore clears `archivedAt`. Versions and content files remain unchanged.
121
+ Artifacts are not deleted by P0 behavior. Archive sets `archivedAt` and hides the artifact from default list results. `includeArchived=true` includes archived records. Restore clears `archivedAt`. Regular archive/restore leaves versions and content files unchanged. Administrators may repair or delete individual versions from `/admin/artifacts/:id/versions`; the last remaining version cannot be deleted.
119
122
 
120
123
  ## Bundle Format
121
124
 
@@ -347,11 +347,14 @@ tests.
347
347
  - `GET /artifacts/:id`: browser viewer.
348
348
  - `GET /artifacts/:id/react-frame?version=n`: gated React renderer frame. Returns content only when `ARTIFACTY_ENABLE_REACT_RENDERER=true`.
349
349
  - `GET /artifacts/:id/edit`: browser version editor.
350
- - `POST /artifacts/:id/edit`: append a version from the browser editor.
350
+ - `POST /artifacts/:id/edit`: append a version from the browser editor. Unchanged browser edits record `update-noop` without creating another version.
351
351
  - `POST /artifacts/:id/archive`: archive from the browser.
352
352
  - `POST /artifacts/:id/restore`: restore from the browser.
353
353
  - `GET /artifacts/:id/diff`: compare two versions.
354
354
  - `GET /artifacts/:id/raw?version=n`: raw content.
355
+ - `GET /admin/artifacts/:id/versions`: administrator-only version repair/delete screen.
356
+ - `POST /admin/artifacts/:id/versions/:version/repair`: administrator-only replacement of one version's content and format.
357
+ - `POST /admin/artifacts/:id/versions/:version/delete`: administrator-only deletion of one version. The last remaining version cannot be deleted.
355
358
 
356
359
  When `ARTIFACTY_API_TOKEN` is configured, `/api/*` routes require either `Authorization: Bearer <token>` or `x-artifacty-token: <token>`. When users exist, personal tokens issued from `/account` also authenticate API and MCP requests, set created artifacts' `publisherId` to the token owner's email, and map audit `actor` to the same identity. Browser forms can also carry `?token=<token>` in the URL, which is copied to hidden form fields for local team workflows.
357
360
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "artifacty",
3
- "version": "0.10.5",
3
+ "version": "0.10.6",
4
4
  "description": "Local artifact exchange for heterogeneous LLM agents via HTTP and MCP.",
5
5
  "type": "module",
6
6
  "keywords": [
package/src/lib/render.js CHANGED
@@ -189,6 +189,83 @@ export function renderNewArtifactPage({ baseUrl, authToken = "", locale = DEFAUL
189
189
  return renderArtifactFormPage({ mode: "new", baseUrl, authToken, locale, currentPath });
190
190
  }
191
191
 
192
+ export function renderAdminArtifactVersionsPage({ artifact, selectedVersion, content, baseUrl, user, error = "", locale = DEFAULT_LOCALE, currentPath = "/" }) {
193
+ const view = viewContext(locale, currentPath);
194
+ const artifactPath = `/artifacts/${encodeURIComponent(artifact.id)}`;
195
+ const versionRows = artifact.versions.map((version) => {
196
+ const active = version.version === selectedVersion.version ? " active" : "";
197
+ const deleteDisabled = artifact.versions.length <= 1 ? " disabled" : "";
198
+ return `
199
+ <tr>
200
+ <td><a class="version${active}" href="${view.href(`/admin/artifacts/${encodeURIComponent(artifact.id)}/versions?version=${version.version}`)}">v${version.version}</a></td>
201
+ <td>${formatBadge(version.format)}</td>
202
+ <td>${escapeHtml(version.createdAt)}</td>
203
+ <td><code>${escapeHtml(version.sha256.slice(0, 12))}</code></td>
204
+ <td>${escapeHtml(String(version.sizeBytes))}</td>
205
+ <td>
206
+ <form class="inline-action" method="post" action="/admin/artifacts/${encodeURIComponent(artifact.id)}/versions/${version.version}/delete">
207
+ ${localeInput(view.locale)}
208
+ <input name="reason" placeholder="Reason">
209
+ <button type="submit"${deleteDisabled}>Delete</button>
210
+ </form>
211
+ </td>
212
+ </tr>
213
+ `;
214
+ }).join("");
215
+
216
+ return pageShell({
217
+ title: `Manage versions · ${artifact.title}`,
218
+ body: `
219
+ <header class="topbar">
220
+ <div>
221
+ <h1>Manage versions</h1>
222
+ <p>${escapeHtml(artifact.title)} · ${escapeHtml(artifact.id)} · ${escapeHtml(baseUrl)}</p>
223
+ </div>
224
+ <nav>
225
+ <a href="${view.href(artifactPath)}">Artifact</a>
226
+ <a href="${view.href(`${artifactPath}/diff`)}">${view.text("nav.diff")}</a>
227
+ ${authNav(user)}
228
+ ${languageSwitcher(view)}
229
+ </nav>
230
+ </header>
231
+ <main class="artifact-editor">
232
+ ${error ? `<p class="auth-error">${escapeHtml(error)}</p>` : ""}
233
+ <section class="meta-card">
234
+ <h2>Versions</h2>
235
+ <table class="data-table">
236
+ <thead><tr><th>Version</th><th>Format</th><th>Created</th><th>SHA</th><th>Bytes</th><th>Action</th></tr></thead>
237
+ <tbody>${versionRows}</tbody>
238
+ </table>
239
+ </section>
240
+ <form class="editor-form" method="post" action="/admin/artifacts/${encodeURIComponent(artifact.id)}/versions/${selectedVersion.version}/repair">
241
+ ${localeInput(view.locale)}
242
+ <section class="editor-fields">
243
+ <label class="field">
244
+ <span>${view.text("form.format")}</span>
245
+ ${formatSelect(selectedVersion.format)}
246
+ </label>
247
+ <label class="field">
248
+ <span>Reason</span>
249
+ <input name="reason" value="" autocomplete="off">
250
+ </label>
251
+ </section>
252
+ <section class="field content-field">
253
+ <label for="artifact-content">v${selectedVersion.version} content</label>
254
+ <textarea id="artifact-content" name="content" data-artifacty-editor data-editor-format="${escapeAttribute(selectedVersion.format)}" spellcheck="false" required>${escapeHtml(content)}</textarea>
255
+ </section>
256
+ <footer class="editor-actions">
257
+ <a href="${view.href(`${artifactPath}?version=${selectedVersion.version}`)}">View version</a>
258
+ <button type="submit">Repair version</button>
259
+ </footer>
260
+ </form>
261
+ </main>
262
+ `,
263
+ head: editorHead(),
264
+ afterBody: editorScript(view.locale),
265
+ locale: view.locale
266
+ });
267
+ }
268
+
192
269
  export function renderImportArtifactPage({ baseUrl, authToken = "", locale = DEFAULT_LOCALE, currentPath = "/import" }) {
193
270
  const view = viewContext(locale, currentPath);
194
271
  const title = view.raw("form.importTitle");
@@ -509,7 +586,7 @@ export function renderAdminUsersPage({ baseUrl, user, users = [], importResult =
509
586
  });
510
587
  }
511
588
 
512
- export function renderArtifactPage({ artifact, version, content, baseUrl, authToken = "", locale = DEFAULT_LOCALE, currentPath = "/" }) {
589
+ export function renderArtifactPage({ artifact, version, content, baseUrl, authToken = "", locale = DEFAULT_LOCALE, currentPath = "/", user = null }) {
513
590
  const view = viewContext(locale, currentPath);
514
591
  const versionLinks = artifact.versions
515
592
  .map((item) => {
@@ -543,7 +620,9 @@ export function renderArtifactPage({ artifact, version, content, baseUrl, authTo
543
620
  <a href="${view.href("/")}">${view.text("nav.index")}</a>
544
621
  <a href="${view.href(`/artifacts/${encodeURIComponent(artifact.id)}/edit`)}">${view.text("nav.edit")}</a>
545
622
  <a href="${view.href(`/artifacts/${encodeURIComponent(artifact.id)}/diff`)}">${view.text("nav.diff")}</a>
623
+ ${user?.role === "admin" ? `<a href="${view.href(`/admin/artifacts/${encodeURIComponent(artifact.id)}/versions`)}">Versions</a>` : ""}
546
624
  <a href="${view.href(rawUrl)}">${view.text("nav.raw")}</a>
625
+ ${authNav(user)}
547
626
  ${languageSwitcher(view)}
548
627
  </nav>
549
628
  </header>
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomBytes, randomUUID, scryptSync, timingSafeEqual } from "node:crypto";
2
- import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from "node:fs";
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
3
  import { readFile } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
5
5
  import path from "node:path";
@@ -189,6 +189,21 @@ export async function updateArtifact(store = createStore(), id, input = {}) {
189
189
  let artifact;
190
190
  transaction(db, () => {
191
191
  artifact = findArtifactById(db, id);
192
+ const latest = artifact.versions.find((version) => version.version === artifact.latestVersion);
193
+ if (input.skipNoop && latest) {
194
+ const latestContent = readFileSync(path.join(store.home, latest.path), "utf8");
195
+ if (isNoopVersionUpdate(artifact, latest, latestContent, normalized)) {
196
+ insertAuditRecord(db, {
197
+ action: "update-noop",
198
+ artifactId: artifact.id,
199
+ version: artifact.latestVersion,
200
+ sourceAgent: artifact.sourceAgent,
201
+ audit: input.audit,
202
+ metadata: { reason: "no changes detected" }
203
+ });
204
+ return;
205
+ }
206
+ }
192
207
  const now = new Date().toISOString();
193
208
  const nextVersion = artifact.latestVersion + 1;
194
209
  const version = writeVersionFile(store, artifact.id, nextVersion, normalized, now);
@@ -234,6 +249,159 @@ export async function updateArtifact(store = createStore(), id, input = {}) {
234
249
  }
235
250
  }
236
251
 
252
+ export async function replaceArtifactVersion(store = createStore(), id, versionNumber, input = {}) {
253
+ const secretScan = assertNoSecrets(input, securityConfig());
254
+ input = withSecretScan(input, secretScan);
255
+ const normalized = normalizeArtifactInput(input, { requireContent: true, requireTitle: false });
256
+ const targetVersion = Number(versionNumber);
257
+ if (!Number.isInteger(targetVersion) || targetVersion < 1) {
258
+ throw Object.assign(new Error(`Invalid artifact version: ${versionNumber}`), {
259
+ code: "INVALID_VERSION",
260
+ statusCode: 400
261
+ });
262
+ }
263
+ const db = openDatabase(store);
264
+
265
+ try {
266
+ let artifact;
267
+ transaction(db, () => {
268
+ artifact = findArtifactById(db, id);
269
+ const existing = artifact.versions.find((version) => version.version === targetVersion);
270
+ if (!existing) {
271
+ throw Object.assign(new Error(`Artifact version not found: ${id}@${targetVersion}`), {
272
+ code: "ARTIFACT_VERSION_NOT_FOUND",
273
+ statusCode: 404
274
+ });
275
+ }
276
+
277
+ const now = new Date().toISOString();
278
+ const previousPath = existing.path;
279
+ const repairedMetadata = {
280
+ ...existing.metadata,
281
+ ...normalized.metadata,
282
+ adminRepair: {
283
+ repairedAt: now,
284
+ reason: normalizeOptionalString(input.reason),
285
+ previousSha256: existing.sha256,
286
+ previousSizeBytes: existing.sizeBytes,
287
+ previousPath
288
+ }
289
+ };
290
+ const replacement = writeVersionFile(store, artifact.id, targetVersion, {
291
+ ...normalized,
292
+ metadata: repairedMetadata
293
+ }, existing.createdAt);
294
+
295
+ if (previousPath !== replacement.path) {
296
+ removeVersionFile(store, previousPath);
297
+ }
298
+
299
+ db.prepare(`
300
+ UPDATE artifact_versions
301
+ SET created_at = ?, format = ?, content_type = ?, path = ?, size_bytes = ?, sha256 = ?, metadata_json = ?
302
+ WHERE artifact_id = ? AND version = ?
303
+ `).run(
304
+ replacement.createdAt,
305
+ replacement.format,
306
+ replacement.contentType,
307
+ replacement.path,
308
+ replacement.sizeBytes,
309
+ replacement.sha256,
310
+ JSON.stringify(replacement.metadata || {}),
311
+ artifact.id,
312
+ targetVersion
313
+ );
314
+
315
+ artifact.versions = artifact.versions.map((version) => version.version === targetVersion ? replacement : version);
316
+ artifact.updatedAt = now;
317
+ db.prepare("UPDATE artifacts SET updated_at = ? WHERE id = ?").run(now, artifact.id);
318
+ if (targetVersion === artifact.latestVersion) {
319
+ upsertSearchIndex(db, artifact, replacement, normalized.content);
320
+ }
321
+ insertAuditRecord(db, {
322
+ action: "version-repair",
323
+ artifactId: artifact.id,
324
+ version: targetVersion,
325
+ sourceAgent: artifact.sourceAgent,
326
+ audit: input.audit,
327
+ metadata: {
328
+ reason: normalizeOptionalString(input.reason),
329
+ previousSha256: existing.sha256,
330
+ newSha256: replacement.sha256,
331
+ previousPath
332
+ }
333
+ });
334
+ });
335
+
336
+ return getArtifact(store, id, { version: targetVersion });
337
+ } finally {
338
+ db.close();
339
+ }
340
+ }
341
+
342
+ export async function deleteArtifactVersion(store = createStore(), id, versionNumber, options = {}) {
343
+ const targetVersion = Number(versionNumber);
344
+ if (!Number.isInteger(targetVersion) || targetVersion < 1) {
345
+ throw Object.assign(new Error(`Invalid artifact version: ${versionNumber}`), {
346
+ code: "INVALID_VERSION",
347
+ statusCode: 400
348
+ });
349
+ }
350
+ const db = openDatabase(store);
351
+
352
+ try {
353
+ let artifact;
354
+ transaction(db, () => {
355
+ artifact = findArtifactById(db, id);
356
+ if (artifact.versions.length <= 1) {
357
+ throw Object.assign(new Error("Cannot delete the only version of an artifact"), {
358
+ code: "ONLY_VERSION_DELETE_BLOCKED",
359
+ statusCode: 400
360
+ });
361
+ }
362
+ const existing = artifact.versions.find((version) => version.version === targetVersion);
363
+ if (!existing) {
364
+ throw Object.assign(new Error(`Artifact version not found: ${id}@${targetVersion}`), {
365
+ code: "ARTIFACT_VERSION_NOT_FOUND",
366
+ statusCode: 404
367
+ });
368
+ }
369
+
370
+ const now = new Date().toISOString();
371
+ db.prepare("DELETE FROM artifact_versions WHERE artifact_id = ? AND version = ?").run(artifact.id, targetVersion);
372
+ removeVersionFile(store, existing.path);
373
+ artifact.versions = artifact.versions.filter((version) => version.version !== targetVersion);
374
+ artifact.latestVersion = Math.max(...artifact.versions.map((version) => version.version));
375
+ artifact.updatedAt = now;
376
+ db.prepare("UPDATE artifacts SET latest_version = ?, updated_at = ? WHERE id = ?").run(
377
+ artifact.latestVersion,
378
+ now,
379
+ artifact.id
380
+ );
381
+
382
+ const latest = artifact.versions.find((version) => version.version === artifact.latestVersion);
383
+ const latestContent = readFileSync(path.join(store.home, latest.path), "utf8");
384
+ upsertSearchIndex(db, artifact, latest, latestContent);
385
+ insertAuditRecord(db, {
386
+ action: "version-delete",
387
+ artifactId: artifact.id,
388
+ version: targetVersion,
389
+ sourceAgent: artifact.sourceAgent,
390
+ audit: options.audit,
391
+ metadata: {
392
+ reason: normalizeOptionalString(options.reason),
393
+ deletedSha256: existing.sha256,
394
+ deletedPath: existing.path
395
+ }
396
+ });
397
+ });
398
+
399
+ return withLatestContent(store, artifact);
400
+ } finally {
401
+ db.close();
402
+ }
403
+ }
404
+
237
405
  export async function archiveArtifact(store = createStore(), id, options = {}) {
238
406
  const db = openDatabase(store);
239
407
  try {
@@ -1691,6 +1859,38 @@ function writeVersionFile(store, id, versionNumber, input, createdAt) {
1691
1859
  };
1692
1860
  }
1693
1861
 
1862
+ function removeVersionFile(store, relativePath) {
1863
+ const absolutePath = path.join(store.home, relativePath);
1864
+ if (existsSync(absolutePath)) {
1865
+ rmSync(absolutePath, { force: true });
1866
+ }
1867
+ }
1868
+
1869
+ function isNoopVersionUpdate(artifact, latest, latestContent, normalized) {
1870
+ const nextTitle = normalized.title || artifact.title;
1871
+ const nextSourceAgent = normalized.sourceAgent || artifact.sourceAgent;
1872
+ const nextArtifactType = normalized.artifactType || artifact.artifactType;
1873
+ const nextSchemaVersion = normalized.schemaVersion || artifact.schemaVersion;
1874
+ const nextTags = normalized.tags.length > 0 ? normalized.tags : artifact.tags;
1875
+ const nextContentType = normalized.contentType || contentTypeForFormat(normalized.format);
1876
+
1877
+ return nextTitle === artifact.title &&
1878
+ nextSourceAgent === artifact.sourceAgent &&
1879
+ nextArtifactType === artifact.artifactType &&
1880
+ nextSchemaVersion === artifact.schemaVersion &&
1881
+ tagsEqual(nextTags, artifact.tags) &&
1882
+ normalized.content === latestContent &&
1883
+ normalized.format === latest.format &&
1884
+ nextContentType === latest.contentType;
1885
+ }
1886
+
1887
+ function tagsEqual(left = [], right = []) {
1888
+ if (left.length !== right.length) {
1889
+ return false;
1890
+ }
1891
+ return left.every((item, index) => item === right[index]);
1892
+ }
1893
+
1694
1894
  async function withLatestContent(store, artifact) {
1695
1895
  const latest = await readArtifactVersion(store, artifact, artifact.latestVersion);
1696
1896
  return {
package/src/server.js CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  createSession,
15
15
  createStore,
16
16
  createUser,
17
+ deleteArtifactVersion,
17
18
  getArtifact,
18
19
  getSessionUser,
19
20
  importUsersFromCsv,
@@ -25,6 +26,7 @@ import {
25
26
  revokeApiToken,
26
27
  revokeSession,
27
28
  restoreArtifact,
29
+ replaceArtifactVersion,
28
30
  setUserActive,
29
31
  updateArtifact,
30
32
  verifyUserPassword
@@ -41,6 +43,7 @@ import {
41
43
  renderArtifactFormPage,
42
44
  renderArtifactPage,
43
45
  renderAccountPage,
46
+ renderAdminArtifactVersionsPage,
44
47
  renderAdminUsersPage,
45
48
  renderPasswordPage,
46
49
  renderReactFramePage,
@@ -410,6 +413,63 @@ export async function handleRequest({ request, response, store, host, port, secu
410
413
  return sendRedirect(response, "/admin/users");
411
414
  }
412
415
 
416
+ const adminVersionsMatch = /^\/admin\/artifacts\/([^/]+)\/versions$/.exec(pathname);
417
+ if (adminVersionsMatch && method === "GET") {
418
+ if (!currentUser) {
419
+ return sendRedirect(response, "/login");
420
+ }
421
+ requireAdmin(currentUser);
422
+ const artifact = await getArtifact(store, adminVersionsMatch[1], {
423
+ version: url.searchParams.get("version") || undefined
424
+ });
425
+ return sendHtml(response, renderAdminArtifactVersionsPage({
426
+ artifact,
427
+ selectedVersion: artifact.version,
428
+ content: artifact.content,
429
+ baseUrl,
430
+ user: currentUser,
431
+ locale,
432
+ currentPath
433
+ }), 200, headOnly);
434
+ }
435
+
436
+ const adminVersionActionMatch = /^\/admin\/artifacts\/([^/]+)\/versions\/(\d+)\/(repair|delete)$/.exec(pathname);
437
+ if (adminVersionActionMatch && method === "POST") {
438
+ assertLocalOrigin(request);
439
+ if (!currentUser) {
440
+ return sendRedirect(response, "/login");
441
+ }
442
+ requireAdmin(currentUser);
443
+ request.artifactyAuth = {
444
+ type: "session",
445
+ actor: currentUser.email,
446
+ user: currentUser,
447
+ role: currentUser.role
448
+ };
449
+ const body = await readFormBody(request);
450
+ const bodyLocale = localeFromBodyOrUrl(body, url);
451
+ const artifactId = adminVersionActionMatch[1];
452
+ const versionNumber = Number(adminVersionActionMatch[2]);
453
+ const action = adminVersionActionMatch[3];
454
+ if (action === "repair") {
455
+ await replaceArtifactVersion(store, artifactId, versionNumber, {
456
+ content: body.content,
457
+ format: body.format,
458
+ reason: body.reason,
459
+ metadata: {
460
+ repairedVia: "artifacty-admin"
461
+ },
462
+ audit: auditContext(request, "web-admin")
463
+ });
464
+ } else {
465
+ await deleteArtifactVersion(store, artifactId, versionNumber, {
466
+ reason: body.reason,
467
+ audit: auditContext(request, "web-admin")
468
+ });
469
+ }
470
+ return sendRedirect(response, localizedHref(`/admin/artifacts/${encodeURIComponent(artifactId)}/versions`, bodyLocale));
471
+ }
472
+
413
473
  if (pathname === "/mcp") {
414
474
  if (!mcpHttp) {
415
475
  return sendJson(response, { error: "Not found" }, 404, headOnly);
@@ -585,6 +645,7 @@ export async function handleRequest({ request, response, store, host, port, secu
585
645
  metadata: {
586
646
  updatedVia: "artifacty-web"
587
647
  },
648
+ skipNoop: true,
588
649
  audit: auditContext(request, "web")
589
650
  });
590
651
  return sendRedirect(response, localizedHref(`/artifacts/${encodeURIComponent(artifact.id)}`, bodyLocale));
@@ -697,7 +758,8 @@ export async function handleRequest({ request, response, store, host, port, secu
697
758
  baseUrl,
698
759
  authToken,
699
760
  locale,
700
- currentPath
761
+ currentPath,
762
+ user: currentUser
701
763
  }), 200, headOnly);
702
764
  }
703
765