artifacty 0.10.4 → 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 +11 -4
- package/docs/artifact-schema-v1.md +5 -2
- package/docs/integrations.md +5 -2
- package/package.json +1 -1
- package/src/lib/converters.js +35 -6
- package/src/lib/render.js +80 -1
- package/src/lib/storage.js +217 -5
- package/src/server.js +63 -1
package/README.md
CHANGED
|
@@ -163,6 +163,11 @@ the payload explicitly identifies the agent through `agent` or `sourceAgent`.
|
|
|
163
163
|
Plain Markdown from these agents stays a normal
|
|
164
164
|
`document` unless you pass an explicit `artifactType`.
|
|
165
165
|
|
|
166
|
+
When `format` is omitted, Artifacty infers common formats from filename,
|
|
167
|
+
content type, and content. HTML files and HTML fragments such as
|
|
168
|
+
`<section>...</section>` are stored as `html` so the browser viewer renders
|
|
169
|
+
them as HTML instead of plain text.
|
|
170
|
+
|
|
166
171
|
## Agent Handoff Example
|
|
167
172
|
|
|
168
173
|
One agent can publish a continuation artifact, then another agent can discover it, read the context, and append the next version.
|
|
@@ -247,7 +252,7 @@ By default Artifacty stores files under `~/.artifacty`.
|
|
|
247
252
|
ARTIFACTY_HOME=/path/to/shared/store artifacty serve
|
|
248
253
|
```
|
|
249
254
|
|
|
250
|
-
Artifact metadata is stored in `artifacty.sqlite`; artifact content is stored as
|
|
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.
|
|
251
256
|
|
|
252
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:
|
|
253
258
|
|
|
@@ -297,8 +302,9 @@ Browser routes:
|
|
|
297
302
|
- `/`: list artifacts with search, tag, and source filters.
|
|
298
303
|
- `/new`: create an Artifacty-native artifact with the CodeMirror editor.
|
|
299
304
|
- `/import`: paste an external agent artifact and convert it with automatic editor mode detection.
|
|
300
|
-
- `/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.
|
|
301
306
|
- `/artifacts/:id/diff`: compare versions.
|
|
307
|
+
- `/admin/artifacts/:id/versions`: administrator-only repair/delete screen for individual versions.
|
|
302
308
|
- `/api/audit`: list audit events.
|
|
303
309
|
|
|
304
310
|
List APIs support pagination with `limit` and `offset`. Responses keep the top-level `artifacts` array and include `pagination` and `search` metadata:
|
|
@@ -315,9 +321,10 @@ The browser UI defaults to English. Add `?lang=ko` to any browser route to use K
|
|
|
315
321
|
Schema and storage:
|
|
316
322
|
|
|
317
323
|
- Metadata lives in SQLite with `schemaVersion: 1`, `artifactType`, `publisherId`, and `archivedAt`.
|
|
318
|
-
- 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.
|
|
319
325
|
- Bundle artifacts store multiple files or base64 assets as portable JSON.
|
|
320
326
|
- Supported formats are `html`, `markdown`, `text`, `json`, `code`, `svg`, `mermaid`, `react`, `sarif`, `csv`, `image`, and `video`.
|
|
327
|
+
- Native create/import paths infer `html` from HTML documents or fragments when no explicit format is supplied.
|
|
321
328
|
- Diagram, component, source snippet, analysis report, table, and media assets use `diagram`, `component`, `snippet`, `analysis-report`, `table`, and `asset` artifact types.
|
|
322
329
|
- Copilot/Cursor examples cover PR reviews, screenshots, demo recordings, and visual evidence bundles.
|
|
323
330
|
- See [docs/artifact-schema-v1.md](docs/artifact-schema-v1.md).
|
|
@@ -335,7 +342,7 @@ Schema and storage:
|
|
|
335
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).
|
|
336
343
|
- Non-local binding prints a startup warning because Artifacty does not terminate TLS.
|
|
337
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.
|
|
338
|
-
- Creates, updates, reads, imports, archives, and
|
|
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.
|
|
339
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`.
|
|
340
347
|
- Mutating HTTP routes reject non-local browser origins.
|
|
341
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
|
-
|
|
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`.
|
|
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
|
|
package/docs/integrations.md
CHANGED
|
@@ -319,7 +319,7 @@ Supported converter inputs:
|
|
|
319
319
|
- GitHub Copilot: markdown/text/json outputs; Artifacty-compatible JSON payloads; structured handoff, review, diff, and verification JSON payloads with `agent` or `sourceAgent` set to `github-copilot` or `copilot`.
|
|
320
320
|
- Cursor: markdown/text/json outputs; Artifacty-compatible JSON payloads; structured handoff, review, diff, verification, screenshot, demo/video, and visual evidence bundle JSON payloads with `agent` or `sourceAgent` set to `cursor`.
|
|
321
321
|
- Gemini: `returnDisplay`, `llmContent`, text blocks, or local markdown/text/json files.
|
|
322
|
-
- Generic: file extension, content type, HTML doctype, JSON shape, media data URLs, and markdown headings are used to infer format and title.
|
|
322
|
+
- Generic: file extension, content type, HTML doctype/fragments, JSON shape, media data URLs, and markdown headings are used to infer format and title. Generic `text/plain` uploads are upgraded to a more specific format, such as `html`, when filename or content makes that clear.
|
|
323
323
|
|
|
324
324
|
The converter adds `imported` and source-agent tags, preserves the raw content as an immutable Artifacty version, and records source details under `metadata.artifactyImport`.
|
|
325
325
|
|
|
@@ -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
package/src/lib/converters.js
CHANGED
|
@@ -36,7 +36,12 @@ export function convertAgentArtifact(input = {}) {
|
|
|
36
36
|
optionalString(decoded.title) ||
|
|
37
37
|
inferTitle({ content, format, fileName, sourceAgent });
|
|
38
38
|
|
|
39
|
-
const contentType =
|
|
39
|
+
const contentType = resolvedContentType({
|
|
40
|
+
format,
|
|
41
|
+
explicitContentType,
|
|
42
|
+
decodedContentType: decoded.contentType,
|
|
43
|
+
mediaMimeType: media?.mimeType
|
|
44
|
+
});
|
|
40
45
|
const artifactType = normalizeArtifactType(
|
|
41
46
|
input.artifactType ||
|
|
42
47
|
input.artifact_type ||
|
|
@@ -77,6 +82,14 @@ export function convertAgentArtifact(input = {}) {
|
|
|
77
82
|
};
|
|
78
83
|
}
|
|
79
84
|
|
|
85
|
+
function resolvedContentType({ format, explicitContentType, decodedContentType, mediaMimeType }) {
|
|
86
|
+
const candidate = explicitContentType || decodedContentType || mediaMimeType;
|
|
87
|
+
if (candidate && !(isGenericTextContentType(candidate) && format !== "text")) {
|
|
88
|
+
return candidate;
|
|
89
|
+
}
|
|
90
|
+
return mediaMimeType || contentTypeForFormat(format);
|
|
91
|
+
}
|
|
92
|
+
|
|
80
93
|
export function detectFormat({ content = "", contentType = "", fileName = "" } = {}) {
|
|
81
94
|
const type = optionalString(contentType).toLowerCase();
|
|
82
95
|
if (type.includes("vnd.ant.code") || type.includes("source-code")) {
|
|
@@ -112,10 +125,6 @@ export function detectFormat({ content = "", contentType = "", fileName = "" } =
|
|
|
112
125
|
if (type.includes("json")) {
|
|
113
126
|
return "json";
|
|
114
127
|
}
|
|
115
|
-
if (type.startsWith("text/")) {
|
|
116
|
-
return "text";
|
|
117
|
-
}
|
|
118
|
-
|
|
119
128
|
const lowerName = optionalString(fileName).toLowerCase();
|
|
120
129
|
if (lowerName.endsWith(".sarif") || lowerName.endsWith(".sarif.json")) {
|
|
121
130
|
return "sarif";
|
|
@@ -172,7 +181,7 @@ export function detectFormat({ content = "", contentType = "", fileName = "" } =
|
|
|
172
181
|
if (looksLikeMediaDataUrl(trimmed, "video")) {
|
|
173
182
|
return "video";
|
|
174
183
|
}
|
|
175
|
-
if (
|
|
184
|
+
if (looksLikeHtml(trimmed)) {
|
|
176
185
|
return "html";
|
|
177
186
|
}
|
|
178
187
|
if (looksLikeSarif(trimmed)) {
|
|
@@ -187,6 +196,9 @@ export function detectFormat({ content = "", contentType = "", fileName = "" } =
|
|
|
187
196
|
if (/^#{1,3}\s+\S/m.test(trimmed) || /^[-*]\s+\S/m.test(trimmed)) {
|
|
188
197
|
return "markdown";
|
|
189
198
|
}
|
|
199
|
+
if (type.startsWith("text/")) {
|
|
200
|
+
return "text";
|
|
201
|
+
}
|
|
190
202
|
return "text";
|
|
191
203
|
}
|
|
192
204
|
|
|
@@ -1175,6 +1187,19 @@ function isSarifObject(value) {
|
|
|
1175
1187
|
(typeof value.version === "string" || optionalString(value.$schema).toLowerCase().includes("sarif"));
|
|
1176
1188
|
}
|
|
1177
1189
|
|
|
1190
|
+
function looksLikeHtml(value) {
|
|
1191
|
+
const trimmed = optionalString(value);
|
|
1192
|
+
if (/^<!doctype html/i.test(trimmed) || /^<html[\s>]/i.test(trimmed)) {
|
|
1193
|
+
return true;
|
|
1194
|
+
}
|
|
1195
|
+
const withoutLeadingComment = trimmed.replace(/^<!--[\s\S]*?-->\s*/, "");
|
|
1196
|
+
return htmlTagPattern().test(withoutLeadingComment);
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
function htmlTagPattern() {
|
|
1200
|
+
return /^<\/?(?:a|article|aside|body|br|button|canvas|code|div|fieldset|figcaption|figure|footer|form|h[1-6]|head|header|hr|iframe|img|input|label|li|link|main|meta|nav|ol|option|p|pre|script|section|select|span|style|table|tbody|td|textarea|tfoot|th|thead|title|tr|ul|video|audio)(?:\s|>|\/)/i;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1178
1203
|
function looksLikeCsv(value) {
|
|
1179
1204
|
const lines = optionalString(value)
|
|
1180
1205
|
.split(/\r?\n/)
|
|
@@ -1307,6 +1332,10 @@ function isSupportedVideoMime(value) {
|
|
|
1307
1332
|
return new Set(["video/mp4", "video/webm"]).has(optionalString(value).toLowerCase().split(";")[0]);
|
|
1308
1333
|
}
|
|
1309
1334
|
|
|
1335
|
+
function isGenericTextContentType(value) {
|
|
1336
|
+
return optionalString(value).toLowerCase().split(";")[0] === "text/plain";
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1310
1339
|
function looksLikeMermaid(value) {
|
|
1311
1340
|
const firstMeaningfulLine = optionalString(value)
|
|
1312
1341
|
.split(/\r?\n/)
|
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>
|
package/src/lib/storage.js
CHANGED
|
@@ -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 {
|
|
@@ -1721,7 +1921,7 @@ function normalizeArtifactInput(input, options) {
|
|
|
1721
1921
|
return {
|
|
1722
1922
|
title,
|
|
1723
1923
|
content,
|
|
1724
|
-
format: normalizeFormat(input.format || inferFormat(input.contentType)),
|
|
1924
|
+
format: normalizeFormat(input.format || inferFormat(input.contentType, content)),
|
|
1725
1925
|
contentType: normalizeOptionalString(input.contentType),
|
|
1726
1926
|
artifactType: normalizeArtifactType(input.artifactType || input.artifact_type || inferArtifactType(input)),
|
|
1727
1927
|
schemaVersion: normalizeSchemaVersion(input.schemaVersion || input.schema_version),
|
|
@@ -1758,9 +1958,9 @@ function normalizeSchemaVersion(value) {
|
|
|
1758
1958
|
function inferArtifactType(input) {
|
|
1759
1959
|
let format;
|
|
1760
1960
|
try {
|
|
1761
|
-
format = normalizeFormat(input.format || inferFormat(input.contentType));
|
|
1961
|
+
format = normalizeFormat(input.format || inferFormat(input.contentType, input.content));
|
|
1762
1962
|
} catch {
|
|
1763
|
-
format = normalizeOptionalString(input.format || inferFormat(input.contentType));
|
|
1963
|
+
format = normalizeOptionalString(input.format || inferFormat(input.contentType, input.content));
|
|
1764
1964
|
}
|
|
1765
1965
|
if (format === "html") {
|
|
1766
1966
|
return "html-page";
|
|
@@ -2011,7 +2211,7 @@ function verifyPassword(password, stored) {
|
|
|
2011
2211
|
return actualBuffer.length === expectedBuffer.length && timingSafeEqual(actualBuffer, expectedBuffer);
|
|
2012
2212
|
}
|
|
2013
2213
|
|
|
2014
|
-
function inferFormat(contentType) {
|
|
2214
|
+
function inferFormat(contentType, content = "") {
|
|
2015
2215
|
const value = normalizeOptionalString(contentType).toLowerCase();
|
|
2016
2216
|
if (value.includes("vnd.ant.code") || value.includes("source-code")) {
|
|
2017
2217
|
return "code";
|
|
@@ -2046,9 +2246,21 @@ function inferFormat(contentType) {
|
|
|
2046
2246
|
if (value.includes("json")) {
|
|
2047
2247
|
return "json";
|
|
2048
2248
|
}
|
|
2249
|
+
const trimmed = normalizeOptionalString(content);
|
|
2250
|
+
if (looksLikeHtml(trimmed)) {
|
|
2251
|
+
return "html";
|
|
2252
|
+
}
|
|
2049
2253
|
return "text";
|
|
2050
2254
|
}
|
|
2051
2255
|
|
|
2256
|
+
function looksLikeHtml(value) {
|
|
2257
|
+
if (/^<!doctype html/i.test(value) || /^<html[\s>]/i.test(value)) {
|
|
2258
|
+
return true;
|
|
2259
|
+
}
|
|
2260
|
+
const withoutLeadingComment = value.replace(/^<!--[\s\S]*?-->\s*/, "");
|
|
2261
|
+
return /^<\/?(?:a|article|aside|body|br|button|canvas|code|div|fieldset|figcaption|figure|footer|form|h[1-6]|head|header|hr|iframe|img|input|label|li|link|main|meta|nav|ol|option|p|pre|script|section|select|span|style|table|tbody|td|textarea|tfoot|th|thead|title|tr|ul|video|audio)(?:\s|>|\/)/i.test(withoutLeadingComment);
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2052
2264
|
function makeArtifactId(title) {
|
|
2053
2265
|
const slug = title
|
|
2054
2266
|
.toLowerCase()
|
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
|
|