artifacty 0.11.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lib/render.js CHANGED
@@ -37,7 +37,7 @@ export function renderDashboard({ artifacts, baseUrl, filters = {}, pagination,
37
37
  <a href="${view.href("/new")}">${view.text("nav.new")}</a>
38
38
  <a href="${view.href("/import")}">${view.text("nav.import")}</a>
39
39
  <a href="/api/artifacts">${view.text("nav.api")}</a>
40
- ${authNav(user)}
40
+ ${authNav(user, view)}
41
41
  ${languageSwitcher(view)}
42
42
  </nav>
43
43
  </header>
@@ -379,7 +379,7 @@ export function renderAdminArtifactVersionsPage({ artifact, selectedVersion, con
379
379
  <nav>
380
380
  <a href="${view.href(artifactPath)}">Artifact</a>
381
381
  <a href="${view.href(`${artifactPath}/diff`)}">${view.text("nav.diff")}</a>
382
- ${authNav(user)}
382
+ ${authNav(user, view)}
383
383
  ${languageSwitcher(view)}
384
384
  </nav>
385
385
  </header>
@@ -563,7 +563,7 @@ export function renderAccountPage({ baseUrl, user, tokens = [], createdToken = "
563
563
  </div>
564
564
  <nav>
565
565
  <a href="${view.href("/")}">${view.text("nav.index")}</a>
566
- ${user.role === "admin" ? `<a href="/admin/users">Users</a><a href="/admin/backup">Backup</a>` : ""}
566
+ ${user.role === "admin" ? `<a href="/admin/users">Users</a><a href="/admin/backup">Backup</a><a href="${view.href("/admin/access")}">${view.text("access.title")}</a>` : ""}
567
567
  <a href="/account/password">Password</a>
568
568
  <form class="nav-form" method="post" action="/logout"><button type="submit">Sign out</button></form>
569
569
  ${languageSwitcher(view)}
@@ -682,7 +682,7 @@ export function renderAdminBackupPage({ baseUrl, integrity, importResult = null,
682
682
  </div>
683
683
  <nav>
684
684
  <a href="${view.href("/")}">${view.text("nav.index")}</a>
685
- <a href="/admin/users">Users</a>
685
+ <a href="/admin/users">Users</a><a href="${view.href("/admin/access")}">${view.text("access.title")}</a>
686
686
  <a href="/admin/retention">${view.text("retention.navLabel")}</a>
687
687
  <a href="/account">Account</a>
688
688
  ${languageSwitcher(view)}
@@ -737,6 +737,116 @@ export function renderAdminBackupPage({ baseUrl, integrity, importResult = null,
737
737
  });
738
738
  }
739
739
 
740
+ export function renderAdminAccessPage({ baseUrl, user, mode, teams = [], users = [], networkPolicy = { allowedCidrs: [] }, currentIp = "", preview = null, pendingNetworkPolicy = null, shareLinkPolicy = {}, locale = DEFAULT_LOCALE, currentPath = "/admin/access" }) {
741
+ const view = viewContext(locale, currentPath);
742
+ return pageShell({
743
+ title: view.text("access.title"),
744
+ locale: view.locale,
745
+ body: `<header class="topbar"><div><h1>${view.text("access.title")}</h1><p>${escapeHtml(baseUrl)}</p></div>
746
+ <nav><a href="${view.href("/")}">${view.text("nav.index")}</a>${authNav(user, view)}${languageSwitcher(view)}</nav></header>
747
+ <main class="artifact-view">
748
+ <section class="meta-card"><h2>${view.text("access.mode")}</h2>
749
+ <form class="editor-form" method="post" action="/admin/access/preview">${localeInput(view.locale)}
750
+ <label><input type="radio" name="mode" value="public"${mode === "public" ? " checked" : ""}> ${view.text("access.public")}</label>
751
+ <p class="muted">${view.text("access.publicHelp")}</p>
752
+ <label><input type="radio" name="mode" value="private"${mode === "private" ? " checked" : ""}> ${view.text("access.private")}</label>
753
+ <p class="muted">${view.text("access.privateHelp")}</p>
754
+ <button type="submit">${view.text("access.preview")}</button>
755
+ </form>
756
+ </section>
757
+ ${preview ? `<section class="meta-card"><h2>${view.text("access.preview")}</h2><p>${view.text("access.previewCount", { count: preview.anonymousArtifactCount, mode: preview.mode })}</p>
758
+ <form method="post" action="/admin/access">${localeInput(view.locale)}<input type="hidden" name="mode" value="${escapeAttribute(preview.mode)}"><button type="submit">${view.text("access.save")}</button></form></section>` : ""}
759
+ <section class="meta-card"><h2>${view.text("sharing.links")}</h2><form method="post" action="/admin/access/share-links">${localeInput(view.locale)}
760
+ <label><input type="checkbox" name="enabledInPrivate" value="true"${shareLinkPolicy.enabledInPrivate ? " checked" : ""}> ${view.text("sharing.enablePrivate")}</label><button type="submit">${view.text("access.save")}</button></form></section>
761
+ ${pendingNetworkPolicy ? `<section class="meta-card"><h2>${view.text("access.pendingNetwork")}</h2><p>${view.text("access.pendingHelp", { expiresAt: pendingNetworkPolicy.expiresAt })}</p>
762
+ ${["confirm", "rollback"].map((action) => `<form method="post" action="/admin/access/network/${action}">${localeInput(view.locale)}<input type="hidden" name="id" value="${escapeAttribute(pendingNetworkPolicy.id)}"><button type="submit">${view.text(`access.${action}`)}</button></form>`).join("")}</section>` : ""}
763
+ <section class="meta-card"><h2>${view.text("access.network")}</h2>
764
+ <p class="muted">${view.text("access.networkHelp")}</p>
765
+ <p>${view.text("access.currentIp")}: <code>${escapeHtml(currentIp)}</code></p>
766
+ <form class="editor-form" method="post" action="/admin/access/network">${localeInput(view.locale)}
767
+ <label class="field"><span>${view.text("access.allowedCidrs")}</span><textarea name="allowedCidrs" rows="5">${escapeHtml((networkPolicy.allowedCidrs || []).join("\n"))}</textarea></label>
768
+ <button type="submit">${view.text("access.saveNetwork")}</button>
769
+ </form>
770
+ </section>
771
+ <section class="meta-card"><h2>${view.text("access.samples")}</h2><p>${view.text("access.samplesHelp")}</p>
772
+ ${["install", "remove"].map((action) => `<form method="post" action="/admin/samples/${action}">${localeInput(view.locale)}<button type="submit">${view.text(`access.samples.${action}`)}</button></form>`).join("")}</section>
773
+ <section class="meta-card"><h2>${view.text("access.teams")}</h2>
774
+ <form class="editor-form auth-form" method="post" action="/admin/teams">${localeInput(view.locale)}
775
+ <label class="field"><span>${view.text("access.teamName")}</span><input name="name" required></label>
776
+ <button type="submit">${view.text("access.createTeam")}</button>
777
+ </form>
778
+ ${teams.length ? teams.map((team) => `<section><h3>${escapeHtml(team.name)}</h3>
779
+ <form class="editor-form" method="post" action="/admin/teams/${encodeURIComponent(team.id)}/members">${localeInput(view.locale)}
780
+ <fieldset><legend>${view.text("access.members")}</legend>${accessCheckboxes(users, "userIds", team.memberIds || [], view)}</fieldset>
781
+ <button type="submit">${view.text("access.saveMembers")}</button>
782
+ </form></section>`).join("") : `<p class="muted">${view.text("access.noTeams")}</p>`}
783
+ </section>
784
+ </main>`
785
+ });
786
+ }
787
+
788
+ export function renderArtifactSharingPage({ baseUrl, user, artifact, sharing = { userIds: [], teamIds: [] }, users = [], teams = [], accessReport = null, shareLinks = [], createdShareLink = null, networkPolicy = { allowedCidrs: [] }, locale = DEFAULT_LOCALE, currentPath = "/" }) {
789
+ const view = viewContext(locale, currentPath);
790
+ return pageShell({
791
+ title: view.text("sharing.title"),
792
+ locale: view.locale,
793
+ body: `<header class="topbar"><div><h1>${view.text("sharing.title")}: ${escapeHtml(artifact.title)}</h1><p>${escapeHtml(baseUrl)}</p></div>
794
+ <nav><a href="${view.href(`/artifacts/${encodeURIComponent(artifact.id)}`)}">${view.text("nav.artifact")}</a>${authNav(user, view)}${languageSwitcher(view)}</nav></header>
795
+ <main class="artifact-view"><section class="meta-card">
796
+ <p>${view.text("sharing.help")}</p>
797
+ <form class="editor-form" method="post" action="/artifacts/${encodeURIComponent(artifact.id)}/sharing">${localeInput(view.locale)}
798
+ <fieldset><legend>${view.text("sharing.users")}</legend>${accessCheckboxes(users, "userIds", sharing.userIds || [], view, sharing.userPermissions || {})}</fieldset>
799
+ <fieldset><legend>${view.text("sharing.teams")}</legend>${accessCheckboxes(teams, "teamIds", sharing.teamIds || [], view, sharing.teamPermissions || {})}</fieldset>
800
+ <button type="submit">${view.text("sharing.save")}</button>
801
+ </form>
802
+ </section>
803
+ ${accessReport && (user?.role === "admin" || user?.id === artifact.ownerUserId) ? `<section class="meta-card"><h2>${view.text("sharing.effective")}</h2><p>${view.text("sharing.networkCondition")}: ${escapeHtml(networkPolicy.allowedCidrs?.length ? networkPolicy.allowedCidrs.join(", ") : view.text("sharing.unrestricted"))}</p><p>${view.text("sharing.anonymous")}: ${view.text(accessReport.anonymousRead ? "sharing.allowed" : "sharing.denied")}</p>
804
+ <table><thead><tr><th>${view.text("sharing.users")}</th><th>${view.text("sharing.permission")}</th><th>${view.text("sharing.reason")}</th></tr></thead><tbody>${accessReport.users.map((item) => `<tr><td>${escapeHtml(item.name || item.email || item.id)}</td><td>${view.text(`sharing.permission.${item.permission}`)}</td><td>${escapeHtml(item.reasons.join(", "))}</td></tr>`).join("")}</tbody></table></section>` : ""}
805
+ <section class="meta-card"><h2>${view.text("sharing.links")}</h2><p>${view.text("sharing.linksHelp")}</p>
806
+ ${createdShareLink?.token ? `<p>${view.text("sharing.copyNow")}</p><a href="${escapeAttribute(`${baseUrl.replace(/\/$/, "")}/s/${encodeURIComponent(createdShareLink.token)}`)}">${escapeHtml(`${baseUrl.replace(/\/$/, "")}/s/${encodeURIComponent(createdShareLink.token)}`)}</a>` : ""}
807
+ <form class="editor-form" method="post" action="/artifacts/${encodeURIComponent(artifact.id)}/share-links">${localeInput(view.locale)}
808
+ <label class="field"><span>${view.text("sharing.version")}</span><select name="version"><option value="latest">${view.text("sharing.latest")}</option>${(artifact.versions || []).map((item) => `<option value="${Number(item.version)}">v${Number(item.version)}</option>`).join("")}</select></label>
809
+ <label class="field"><span>${view.text("sharing.days")}</span><input type="number" name="days" min="1" max="365" value="7" required></label><button type="submit">${view.text("sharing.createLink")}</button></form>
810
+ ${shareLinks.map((link) => `<section><p>${escapeHtml(link.id)} · ${link.version == null ? view.text("sharing.latest") : `v${Number(link.version)}`} · ${view.text("sharing.expires")}: ${escapeHtml(link.expiresAt)}</p>
811
+ ${link.revokedAt ? `<p>${view.text("sharing.revoked")}</p>` : `<form method="post" action="/artifacts/${encodeURIComponent(artifact.id)}/share-links/${encodeURIComponent(link.id)}/revoke">${localeInput(view.locale)}<button type="submit">${view.text("sharing.revoke")}</button></form>`}</section>`).join("")}
812
+ </section></main>`
813
+ });
814
+ }
815
+
816
+ function accessCheckboxes(items, name, selectedIds, view, permissions = null) {
817
+ if (!items.length) return `<p class="muted">${view.text(name === "userIds" ? "access.noUsers" : "access.noTeams")}</p>`;
818
+ return items.filter((item) => item.active !== false).map((item) => `<label><input type="checkbox" name="${name}" value="${escapeAttribute(item.id)}"${selectedIds.includes(item.id) ? " checked" : ""}> ${escapeHtml(item.name || item.email || item.id)}${item.email && item.name ? ` (${escapeHtml(item.email)})` : ""}</label>${permissions ? `<label>${view.text("sharing.permission")}<select name="${name === "userIds" ? "userPermission" : "teamPermission"}:${escapeAttribute(item.id)}">${["read", "comment", "edit"].map((permission) => `<option value="${permission}"${(permissions[item.id] || "read") === permission ? " selected" : ""}>${view.text(`sharing.permission.${permission}`)}</option>`).join("")}</select></label>` : ""}`).join("");
819
+ }
820
+
821
+ export function renderInboxPage({ baseUrl, user, notifications = [], unreadCount = 0, locale = DEFAULT_LOCALE, currentPath = "/inbox" }) {
822
+ const view = viewContext(locale, currentPath);
823
+ return pageShell({ title: view.text("inbox.title"), locale: view.locale,
824
+ body: `<header class="topbar"><div><h1>${view.text("inbox.title")}</h1><p>${escapeHtml(baseUrl)}</p></div><nav><a href="${view.href("/")}">${view.text("nav.index")}</a>${authNav(user, view)}${languageSwitcher(view)}</nav></header>
825
+ <main class="artifact-view"><section class="meta-card"><p>${view.text("inbox.unread", { count: unreadCount })}</p>
826
+ <form method="post" action="/inbox/read-all">${localeInput(view.locale)}<button type="submit">${view.text("inbox.readAll")}</button></form>
827
+ ${notifications.length ? notifications.map((item) => `<section><h2><a href="${view.href(`/artifacts/${encodeURIComponent(item.artifactId)}`)}">${escapeHtml(item.title)}</a></h2><p>${escapeHtml(item.type)} · ${escapeHtml(item.createdAt)}</p>
828
+ ${item.readAt ? `<p>${view.text("inbox.read")}</p>` : `<form method="post" action="/inbox/${encodeURIComponent(item.id)}/read">${localeInput(view.locale)}<button type="submit">${view.text("inbox.markRead")}</button></form>`}</section>`).join("") : `<p>${view.text("inbox.empty")}</p>`}
829
+ </section></main>` });
830
+ }
831
+
832
+ export function renderSharedArtifactPage({ artifact, version, content, locale = DEFAULT_LOCALE, sharePath }) {
833
+ const view = viewContext(locale, sharePath);
834
+ const rendered = version.format === "html"
835
+ ? `<p>${view.text("sharing.staticPreview")}</p><iframe class="artifact-frame" sandbox="" referrerpolicy="no-referrer" src="${escapeAttribute(`${sharePath}/raw`)}"></iframe>`
836
+ : ["react", "notebook", "svg", "mermaid"].includes(version.format)
837
+ ? `<p>${view.text("sharing.safeSource")}</p><pre class="artifact-code"><code>${escapeHtml(content)}</code></pre>`
838
+ : renderContent(version.format, content, version.metadata || {}, {
839
+ view, artifactType: artifact.artifactType,
840
+ rawUrl: `${sharePath}/raw`,
841
+ bundleFileUrl: (fileName) => `${sharePath}/raw?file=${encodeURIComponent(fileName)}`
842
+ });
843
+ const needsViewer = ["code", "markdown", "notebook", "sarif", "csv"].includes(version.format);
844
+ return pageShell({ title: artifact.title, locale: view.locale,
845
+ body: `<header class="topbar"><div><h1>${escapeHtml(artifact.title)}</h1><p>${view.text("sharing.readOnly")} · v${Number(version.version)}</p></div></header><main class="artifact-view">${rendered}</main>`,
846
+ head: needsViewer ? editorHead() : "",
847
+ afterBody: `${frameResizeScript()}${needsViewer ? viewerScript() : ""}` });
848
+ }
849
+
740
850
  export function renderAdminUsersPage({ baseUrl, user, users = [], importResult = null, importError = "", locale = DEFAULT_LOCALE, currentPath = "/admin/users" }) {
741
851
  const view = viewContext(locale, currentPath);
742
852
  const rows = users.map((item) => `
@@ -796,7 +906,7 @@ export function renderAdminUsersPage({ baseUrl, user, users = [], importResult =
796
906
  </div>
797
907
  <nav>
798
908
  <a href="${view.href("/")}">${view.text("nav.index")}</a>
799
- <a href="/admin/backup">Backup</a>
909
+ <a href="/admin/backup">Backup</a><a href="${view.href("/admin/access")}">${view.text("access.title")}</a>
800
910
  <a href="/admin/retention">${view.text("retention.navLabel")}</a>
801
911
  <a href="/account">Account</a>
802
912
  ${languageSwitcher(view)}
@@ -878,7 +988,7 @@ export function renderAdminWebhooksPage({ baseUrl, webhooks = [], createdSecret
878
988
  </div>
879
989
  <nav>
880
990
  <a href="${view.href("/")}">${view.text("nav.index")}</a>
881
- <a href="/admin/backup">Backup</a>
991
+ <a href="/admin/backup">Backup</a><a href="${view.href("/admin/access")}">${view.text("access.title")}</a>
882
992
  <a href="/admin/users">Users</a>
883
993
  <a href="/admin/retention">${view.text("retention.navLabel")}</a>
884
994
  <a href="/account">Account</a>
@@ -967,8 +1077,8 @@ export function renderAdminRetentionPage({ baseUrl, policy, lastSweep = null, re
967
1077
  </div>
968
1078
  <nav>
969
1079
  <a href="${view.href("/")}">${view.text("nav.index")}</a>
970
- <a href="/admin/backup">Backup</a>
971
- <a href="/admin/users">Users</a>
1080
+ <a href="/admin/backup">Backup</a><a href="${view.href("/admin/access")}">${view.text("access.title")}</a>
1081
+ <a href="/admin/users">Users</a><a href="${view.href("/admin/access")}">${view.text("access.title")}</a>
972
1082
  <a href="/account">Account</a>
973
1083
  ${languageSwitcher(view)}
974
1084
  </nav>
@@ -1005,8 +1115,11 @@ export function renderAdminRetentionPage({ baseUrl, policy, lastSweep = null, re
1005
1115
  });
1006
1116
  }
1007
1117
 
1008
- export function renderArtifactPage({ artifact, version, content, comments = [], baseUrl, authToken = "", locale = DEFAULT_LOCALE, currentPath = "/", user = null }) {
1118
+ export function renderArtifactPage({ artifact, version, content, comments = [], baseUrl, authToken = "", locale = DEFAULT_LOCALE, currentPath = "/", user = null, permission = null }) {
1009
1119
  const view = viewContext(locale, currentPath);
1120
+ const canEdit = permission == null || ["edit", "owner", "admin"].includes(permission);
1121
+ const canComment = canEdit || permission === "comment";
1122
+ const canArchive = permission == null || ["owner", "admin"].includes(permission);
1010
1123
  const versionLinks = artifact.versions
1011
1124
  .map((item) => {
1012
1125
  const active = item.version === version.version ? " active" : "";
@@ -1062,11 +1175,12 @@ export function renderArtifactPage({ artifact, version, content, comments = [],
1062
1175
  </div>
1063
1176
  <nav>
1064
1177
  <a href="${view.href("/")}">${view.text("nav.index")}</a>
1065
- <a href="${view.href(`/artifacts/${encodeURIComponent(artifact.id)}/edit`)}">${view.text("nav.edit")}</a>
1178
+ ${canEdit ? `<a href="${view.href(`/artifacts/${encodeURIComponent(artifact.id)}/edit`)}">${view.text("nav.edit")}</a>` : ""}
1066
1179
  <a href="${view.href(`/artifacts/${encodeURIComponent(artifact.id)}/diff`)}">${view.text("nav.diff")}</a>
1067
1180
  ${user?.role === "admin" ? `<a href="${view.href(`/admin/artifacts/${encodeURIComponent(artifact.id)}/versions`)}">Versions</a>` : ""}
1068
1181
  <a href="${view.href(rawUrl)}">${view.text("nav.raw")}</a>
1069
- ${authNav(user)}
1182
+ ${user && canManageVisibility ? `<a href="${view.href(`/artifacts/${encodeURIComponent(artifact.id)}/sharing`)}">${view.text("sharing.title")}</a>` : ""}
1183
+ ${authNav(user, view)}
1070
1184
  ${languageSwitcher(view)}
1071
1185
  </nav>
1072
1186
  </header>
@@ -1082,16 +1196,16 @@ export function renderArtifactPage({ artifact, version, content, comments = [],
1082
1196
  <span>${escapeHtml(version.createdAt)}</span>
1083
1197
  <span>${escapeHtml(version.sha256.slice(0, 12))}</span>
1084
1198
  </section>
1085
- <form class="inline-action" method="post" action="/artifacts/${encodeURIComponent(artifact.id)}/${archiveAction}">
1199
+ ${canArchive ? `<form class="inline-action" method="post" action="/artifacts/${encodeURIComponent(artifact.id)}/${archiveAction}">
1086
1200
  ${hiddenToken(authToken)}
1087
1201
  ${localeInput(view.locale)}
1088
1202
  <button type="submit">${archiveLabel}</button>
1089
- </form>
1203
+ </form>` : ""}
1090
1204
  ${visibilityForm}
1091
1205
  <section class="version-strip">${versionLinks}</section>
1092
1206
  ${rendered}
1093
1207
  ${renderRelationsSection(artifact, view)}
1094
- ${renderCommentsSection(artifact, comments, view, { version: version.version, authToken, canModerate: canManageVisibility })}
1208
+ ${renderCommentsSection(artifact, comments, view, { version: version.version, authToken, canModerate: canManageVisibility, canComment })}
1095
1209
  </main>
1096
1210
  `,
1097
1211
  head: needsViewerScript ? editorHead() : "",
@@ -1102,7 +1216,7 @@ export function renderArtifactPage({ artifact, version, content, comments = [],
1102
1216
 
1103
1217
  // --- Comments and review status (roadmap section 5) -----------------------
1104
1218
 
1105
- function renderCommentsSection(artifact, comments, view, { version, authToken, canModerate }) {
1219
+ function renderCommentsSection(artifact, comments, view, { version, authToken, canModerate, canComment = true }) {
1106
1220
  const grouped = new Map();
1107
1221
  for (const comment of comments) {
1108
1222
  if (!grouped.has(comment.version)) {
@@ -1119,7 +1233,7 @@ function renderCommentsSection(artifact, comments, view, { version, authToken, c
1119
1233
  // with diagrams could still embed N * cap base64 srcdoc payloads.
1120
1234
  const diagramState = createMermaidDiagramState();
1121
1235
  const groups = versions
1122
- .map((groupVersion) => renderCommentVersionGroup(artifact, grouped.get(groupVersion), groupVersion, view, { activeVersion: version, authToken, canModerate, diagramState }))
1236
+ .map((groupVersion) => renderCommentVersionGroup(artifact, grouped.get(groupVersion), groupVersion, view, { activeVersion: version, authToken, canModerate, canComment, diagramState }))
1123
1237
  .join("");
1124
1238
 
1125
1239
  return `
@@ -1130,12 +1244,12 @@ function renderCommentsSection(artifact, comments, view, { version, authToken, c
1130
1244
  ${canModerate ? renderReviewStatusForm(artifact, view, authToken) : ""}
1131
1245
  </div>
1132
1246
  ${comments.length === 0 ? `<p class="comments-empty">${view.text("comments.empty")}</p>` : groups}
1133
- ${versions.includes(version) ? "" : renderCommentForm(artifact, view, { version, authToken })}
1247
+ ${!canComment || versions.includes(version) ? "" : renderCommentForm(artifact, view, { version, authToken })}
1134
1248
  </section>
1135
1249
  `;
1136
1250
  }
1137
1251
 
1138
- function renderCommentVersionGroup(artifact, versionComments, groupVersion, view, { activeVersion, authToken, canModerate, diagramState }) {
1252
+ function renderCommentVersionGroup(artifact, versionComments, groupVersion, view, { activeVersion, authToken, canModerate, canComment, diagramState }) {
1139
1253
  const roots = versionComments.filter((comment) => !comment.parentId);
1140
1254
  const repliesByParent = new Map();
1141
1255
  for (const comment of versionComments) {
@@ -1148,21 +1262,21 @@ function renderCommentVersionGroup(artifact, versionComments, groupVersion, view
1148
1262
  }
1149
1263
  const isActiveVersion = groupVersion === activeVersion;
1150
1264
  const items = roots
1151
- .map((root) => renderCommentThread(artifact, root, repliesByParent.get(root.id) || [], view, { authToken, canModerate, allowReply: isActiveVersion, diagramState }))
1265
+ .map((root) => renderCommentThread(artifact, root, repliesByParent.get(root.id) || [], view, { authToken, canModerate, allowReply: isActiveVersion && canComment, canComment, diagramState }))
1152
1266
  .join("");
1153
1267
 
1154
1268
  return `
1155
1269
  <div class="comment-version-group">
1156
1270
  <h3>${view.text("comments.version", { version: groupVersion })}</h3>
1157
1271
  <ul class="comment-list">${items}</ul>
1158
- ${isActiveVersion ? renderCommentForm(artifact, view, { version: groupVersion, authToken }) : ""}
1272
+ ${isActiveVersion && canComment ? renderCommentForm(artifact, view, { version: groupVersion, authToken }) : ""}
1159
1273
  </div>
1160
1274
  `;
1161
1275
  }
1162
1276
 
1163
- function renderCommentThread(artifact, root, replies, view, { authToken, canModerate, allowReply, diagramState }) {
1277
+ function renderCommentThread(artifact, root, replies, view, { authToken, canModerate, allowReply, canComment, diagramState }) {
1164
1278
  const replyItems = replies
1165
- .map((reply) => renderCommentEntry(artifact, reply, view, { authToken, canModerate, isReply: true, diagramState }))
1279
+ .map((reply) => renderCommentEntry(artifact, reply, view, { authToken, canModerate, canComment, isReply: true, diagramState }))
1166
1280
  .join("");
1167
1281
  const replyForm = allowReply
1168
1282
  ? renderCommentForm(artifact, view, { version: root.version, authToken, parentId: root.id, compact: true })
@@ -1170,17 +1284,17 @@ function renderCommentThread(artifact, root, replies, view, { authToken, canMode
1170
1284
 
1171
1285
  return `
1172
1286
  <li class="comment-thread">
1173
- ${renderCommentEntry(artifact, root, view, { authToken, canModerate, isReply: false, diagramState })}
1287
+ ${renderCommentEntry(artifact, root, view, { authToken, canModerate, canComment, isReply: false, diagramState })}
1174
1288
  ${replyItems ? `<ul class="comment-replies">${replyItems}</ul>` : ""}
1175
1289
  ${replyForm}
1176
1290
  </li>
1177
1291
  `;
1178
1292
  }
1179
1293
 
1180
- function renderCommentEntry(artifact, comment, view, { authToken, canModerate, isReply, diagramState }) {
1294
+ function renderCommentEntry(artifact, comment, view, { authToken, canModerate, canComment, isReply, diagramState }) {
1181
1295
  const anchorLabel = commentAnchorLabel(comment.anchor, view);
1182
1296
  const resolved = comment.status === "resolved";
1183
- const resolveForm = !resolved
1297
+ const resolveForm = !resolved && canComment
1184
1298
  ? `<form class="inline-action" method="post" action="/artifacts/${encodeURIComponent(artifact.id)}/comments/${encodeURIComponent(comment.id)}/resolve">
1185
1299
  ${hiddenToken(authToken)}
1186
1300
  ${localeInput(view.locale)}
@@ -3300,11 +3414,11 @@ function languageSwitcher(view) {
3300
3414
  return `<span class="language-switcher">${english}${korean}</span>`;
3301
3415
  }
3302
3416
 
3303
- function authNav(user) {
3417
+ function authNav(user, view) {
3304
3418
  if (!user) {
3305
3419
  return `<a href="/login">Sign in</a>`;
3306
3420
  }
3307
- return `${user.role === "admin" ? `<a href="/admin/users">Users</a><a href="/admin/backup">Backup</a>` : ""}<a href="/account">Account</a>`;
3421
+ return `${user.role === "admin" ? `<a href="/admin/users">Users</a><a href="/admin/backup">Backup</a><a href="${view.href("/admin/access")}">${view.text("access.title")}</a>` : ""}<a href="/account">Account</a><a href="${view.href("/inbox")}">${view.text("inbox.title")}</a>`;
3308
3422
  }
3309
3423
 
3310
3424
  export function escapeHtml(value) {
@@ -0,0 +1,185 @@
1
+ // Stable keys identify installed manuals independently of titles and edits.
2
+ export const sampleCatalog = [
3
+ {
4
+ key: "start-here",
5
+ title: "Artifacty — Start here",
6
+ artifactType: "document",
7
+ format: "markdown",
8
+ tags: ["sample", "manual", "getting-started"],
9
+ content: `# Welcome to Artifacty
10
+
11
+ Artifacty keeps agent outputs in one versioned store. You can use the store through a browser, CLI, JSON API, or MCP. Open the **Feature guide** to see all features. Open **Architecture** to see the system map. Use the **Handoff exercise** to practice a review cycle.
12
+
13
+ ## Start locally
14
+
15
+ Use Node.js 22.5 or newer. From the repository, run:
16
+
17
+ \`\`\`sh
18
+ npm install
19
+ npm start
20
+ \`\`\`
21
+
22
+ Open http://127.0.0.1:8787. The server binds to localhost by default. To start a background server, run \`node src/cli.js serve\`. To keep the server in the terminal, add \`--foreground\`. Use \`node src/cli.js status\`, \`stop\`, and \`doctor\` to inspect it.
23
+
24
+ ## Publish and improve an artifact
25
+
26
+ 1. Create a Markdown artifact in the browser, or run \`node src/cli.js publish --title "My first note" --content "Hello Artifacty" --format markdown\`.
27
+ 2. Open the artifact.
28
+ 3. Edit its content.
29
+ 4. Save a new version. Earlier versions remain available.
30
+ 5. Inspect the version history.
31
+ 6. Compare the versions with a diff.
32
+ 7. Add tags.
33
+ 8. Use search, filters, or saved views to find the artifact again.
34
+ 9. Add a comment.
35
+ 10. Request a review.
36
+ 11. As the owner or administrator, record an approved or changes-requested status.
37
+ 12. Create the initial administrator at **/login**.
38
+ 13. Sign in as the administrator.
39
+ 14. Open **Sharing** on the artifact. Anonymous single-user browsing does not expose Sharing.
40
+ 15. Inspect the effective permissions.
41
+ 16. Grant read, comment, or edit access as needed.
42
+
43
+ ## Choose your access model
44
+
45
+ Before accounts exist, Artifacty operates as a local single-user store. Once accounts exist, use personal accounts and tokens. Create the initial administrator at **/login**. Administrators manage accounts at **/admin/users**. The Access page links to that page. Administrators manage teams, access mode, and network rules at **/admin/access**. In private mode, use personal authentication for ordinary artifact reads and creation. Server-wide tokens do not authorize those operations. Local CLI operations remain trusted store administration.
46
+
47
+ A share link is a separate read-only capability with an expiry time. It does not grant account membership. Default expiry is seven days. Owners can share the latest version or pin one version. Share links while the server is in private mode require explicit administrator opt-in. In public mode, an owner can link a private-visibility artifact without that site toggle. When you no longer need a link, revoke it. An owner change invalidates existing links.
48
+
49
+ ## Keep the manuals safely
50
+
51
+ \`node src/cli.js samples install\` installs these four manuals once per installation scope. Repeating installation skips active manuals. \`samples remove\` archives managed samples. It preserves their content and versions. Installing again restores them without replacing your edits. Matching titles or tags do not make your own artifacts managed samples.
52
+
53
+ Back up before maintenance. This build uses store schema 10. Older builds refuse newer stores. This manual describes the implemented repository behavior, not a package release announcement.`
54
+ },
55
+ {
56
+ key: "feature-guide",
57
+ title: "Artifacty — Feature guide",
58
+ artifactType: "html-page",
59
+ format: "html",
60
+ tags: ["sample", "manual", "reference"],
61
+ content: `<!doctype html>
62
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Artifacty feature guide</title>
63
+ <style>body{font-family:system-ui,sans-serif;line-height:1.65;color:#172b3a;background:#f5f7fa;margin:0}main{max-width:900px;margin:auto;padding:40px 24px}h1,h2{line-height:1.2}h1{font-size:2.4rem}section{background:white;border:1px solid #dce4ec;border-radius:12px;padding:24px;margin:20px 0}code{background:#edf2f7;padding:2px 5px;border-radius:4px}li{margin:6px 0}a{color:#175aa3}footer{color:#526475}</style></head><body><main>
64
+ <header><p>ARTIFACTY / REFERENCE</p><h1>From an agent output to a shared record</h1><p>Use this inventory to choose the right publishing, review, access, and maintenance workflow. Use Node.js 22.5 or newer. The current store schema is 10.</p></header>
65
+ <nav aria-label="Guide sections"><a href="#formats">Formats</a> · <a href="#history">History and discovery</a> · <a href="#collaboration">Collaboration</a> · <a href="#access">Access</a> · <a href="#operations">Operations</a></nav>
66
+ <section id="formats"><h2>Formats and imports</h2><p>The 13 formats are HTML, Markdown, text, JSON, code, SVG, Mermaid, React, SARIF, CSV, image, video, and notebook. Choose an artifact type separately: a document, handoff, code review, test report, dashboard, diagram, component, table, or another supported type.</p><p>Bundles group multiple files under one artifact. Supported binary bundle assets include PDF, DOCX, XLSX, and ZIP. Importers normalize outputs from Claude, Codex, Gemini, GitHub Copilot, Cursor, and generic inputs. Use the browser import screen or <code>artifacty import --agent codex --file handoff.md</code>.</p><p>Artifact content is untrusted. Browser previews use constrained rendering. A public HTML share preview is static. It blocks scripts and external resource loading. Share links show other active formats as source. Do not expect those previews to preserve interactive application behavior.</p></section>
67
+ <section id="history"><h2>Versions, discovery, and retrieval</h2><ul><li>Updates create versions with immutable content files. You can read a historical version and compare versions with a diff. To avoid overwriting a concurrent edit, use an expected-version check.</li><li>Archive and restore artifacts instead of losing their history. Integrity checks verify stored files, hashes, and orphaned files.</li><li>Search by keyword and filter by tags, source agent, artifact type, publisher, dates, review status, relationships, and archive state. Pagination and grouping help you use large lists. To reuse a set of filters, save it as a view.</li><li>When available, SQLite FTS5 accelerates keyword search. Otherwise, search uses metadata. Optional embeddings enable semantic and hybrid search. Without a configured provider, those modes use keyword search. Rebuild search or embedding indexes after configuration changes.</li></ul></section>
68
+ <section id="collaboration"><h2>Relationships, comments, review, and notifications</h2><p>Connect artifacts using derived-from, supersedes, reviews, references, or part-of relations. Comments support threads and anchors. After you address a thread, resolve it. Review status progresses among none, pending, changes-requested, and approved.</p><p>The inbox at <code>/inbox</code> collects relevant shares, comments, review activity, and new versions. Notifications depend on the user and current artifact access. You can mark items read individually or together. A notification does not grant access.</p><p>Audit records support investigation. Event polling and server-sent events expose authorized activity streams. Webhooks deliver configured events to validated destinations. Use event filters, replay cursors, and webhook administration for integrations.</p></section>
69
+ <section id="access"><h2>Accounts, permissions, share links, and network rules</h2><p>Create the initial administrator at <code>/login</code>. Administrators manage accounts at <code>/admin/users</code>. The Access page links to that page. Administrators manage teams, access mode, and network rules at <code>/admin/access</code>. The artifact <strong>Sharing</strong> panel shows effective access and its sources. Direct-user and team grants support read, comment, and edit. Where grants overlap, the strongest permission wins. Ownership and administrator management are separate from grants. Token scopes limit token actions. They do not grant artifact access.</p><p>Private mode requires an active personal account. Server-wide tokens do not authorize ordinary artifact reads or creation in private mode. Administrator APIs have their own authorization rules. Personal tokens remain constrained by both their scopes and the user's artifact permissions. Trusted local CLI operations can administer the local store directly.</p><p>Read-only share links default to seven days and can target the latest version or a pinned version. Share links while the server is in private mode require explicit administrator opt-in. In public mode, owners may link private-visibility artifacts without that site toggle. Links can be revoked, and owner changes invalidate them. A valid capability permits only its read-only shared surface.</p><p>Network allow rules accept IP addresses and CIDRs. First, stage a change. Next, verify access from the intended peer. Then confirm the change within 120 seconds or roll it back. Pending state survives a restart. Before the next access, the server checks for expiry. If the change has expired, the server restores the previous rules. Rules use the socket peer address, not forwarded proxy headers.</p></section>
70
+ <section id="operations"><h2>Interfaces and maintenance</h2><ul><li>The browser supports creation, import, viewing, editing, discovery, review, inbox, and administration.</li><li>The CLI supports serve/start/stop/status, publish/import/update/read/list, history and diff, relations/comments/review, saved views, event watching, account import, backup, retention, indexes, integrity, and diagnostics. Run <code>artifacty help</code> for exact flags.</li><li>The JSON API exposes artifacts and operational endpoints under <code>/api/</code>. MCP exposes agent tools through stdio and optional HTTP transport. Use <code>artifacty install claude --dry-run</code> to inspect integration configuration and <code>artifacty check</code> to verify tool discovery.</li><li>Export and import backups to move or recover a store. Retention supports archive rules, archived-item purge, audit/event history limits, and protected tags. Before an intentional purge, inspect a dry run.</li><li><code>artifacty samples install</code> installs these manuals idempotently. <code>artifacty samples remove</code> archives only managed samples in the current scope. Reinstallation restores existing versions and user edits.</li></ul><p>Keep ARTIFACTY_HOME private. Older builds refuse schema-10 stores. Before changing builds, preserve a backup. Consult repository integration documentation for concrete agent and API examples.</p></section>
71
+ <footer><p>This guide is a managed sample artifact. Editing it creates your own version. Reinstalling samples preserves that work.</p></footer>
72
+ </main></body></html>`
73
+ },
74
+ {
75
+ key: "architecture",
76
+ title: "Artifacty — Architecture",
77
+ artifactType: "diagram",
78
+ format: "mermaid",
79
+ tags: ["sample", "manual", "architecture"],
80
+ content: `flowchart TD
81
+ Browser[Browser dashboard and admin] --> HTTP[Local HTTP server and JSON API]
82
+ Agents[Agent clients] --> MCP[MCP stdio and optional HTTP]
83
+ Operator[Trusted local operator] --> CLI[Artifacty CLI]
84
+ HTTP --> Peer[Socket peer network policy]
85
+ Peer --> Auth[Personal accounts and token scopes]
86
+ Auth --> Access[Private mode and effective permissions]
87
+ Access --> Store[Shared storage operations]
88
+ MCP --> Store
89
+ CLI --> Store
90
+ Store --> DB[(SQLite schema 10 metadata)]
91
+ Store --> Files[Immutable version files and bundle assets]
92
+ Store --> Search[FTS5 and optional embeddings]
93
+ Store --> History[Version history and diff]
94
+ Store --> Collaboration[Relations comments and review]
95
+ Collaboration --> Inbox[Access scoped inbox]
96
+ Store --> Events[Audit events SSE and webhooks]
97
+ HTTP --> Links[Read only expiring latest or pinned share links]
98
+ Links --> Preview[Static HTML or active format source]
99
+ Links --> Store
100
+ Admin[Active administrator] --> Policy[Stage network rules for 120 seconds]
101
+ Policy --> Confirm[Confirm or explicit rollback]
102
+ Policy --> Expiry[Durable lazy expiry rollback]
103
+ Confirm --> Peer
104
+ Expiry --> Peer
105
+ CLI --> Maintenance[Backup retention integrity and indexes]
106
+ CLI --> Samples[Install restore or archive managed manuals]
107
+ Samples --> Store`
108
+ },
109
+ {
110
+ key: "handoff-exercise",
111
+ title: "Artifacty — Handoff exercise",
112
+ artifactType: "handoff",
113
+ format: "markdown",
114
+ tags: ["sample", "manual", "handoff", "exercise"],
115
+ content: `# Handoff exercise: review a versioned decision
116
+
117
+ ## Goal
118
+
119
+ Practice passing an artifact from an author to a reviewer while preserving evidence, access, and the next action. Use a disposable artifact of your own. Keep this manual as a reference.
120
+
121
+ ## Current state
122
+
123
+ The four sample manuals are installed. You can open the browser dashboard. To practice multi-user access, first ask an administrator to create the author and reviewer accounts. Local single-user mode does not demonstrate account isolation.
124
+
125
+ ## Exercise
126
+
127
+ 1. Publish a Markdown artifact titled **Exercise: launch checklist**, type **handoff**. Include a goal, current state, evidence, decisions, blockers, and next actions.
128
+ 2. Add an exercise tag.
129
+ 3. Save the artifact ID and initial version number.
130
+ 4. Edit one next action. If you use an agent, include the expected version in the update.
131
+ 5. Save a second version.
132
+ 6. Inspect both versions.
133
+ 7. Compare the versions with a diff.
134
+ 8. Create a separate test-report artifact.
135
+ 9. Add a **references** relation from the handoff to the test report.
136
+ 10. Open the relation from each side.
137
+ 11. In **Sharing**, give the reviewer **comment** permission.
138
+ 12. Inspect the effective permission and its source. If the reviewer has team edit access, that stronger grant wins. Token scopes do not grant artifact access.
139
+ 13. To demonstrate comment-only access, remove any stronger grant.
140
+ 14. Ask the reviewer to add an anchored comment recommending **changes-requested**.
141
+ 15. As the owner or administrator, record that review status.
142
+ 16. Address the comment in a new version.
143
+ 17. Resolve the comment thread.
144
+ 18. When the reviewer agrees that the evidence supports approval, record **approved** as the owner or administrator.
145
+ 19. Check each participant's inbox for relevant sharing, comment, review, and new-version activity.
146
+ 20. Mark notifications read. Notifications remain scoped to current access.
147
+ 21. If the server is in private mode, ask an administrator to enable share links explicitly.
148
+ 22. Create a seven-day read-only share link pinned to the reviewed version.
149
+ 23. Inspect the link preview.
150
+ 24. Edit the artifact again.
151
+ 25. Verify that the pinned link still shows the selected version. A latest-version link follows new versions. An owner change invalidates existing links.
152
+ 26. When the exercise is over, revoke the link.
153
+ 27. Archive your exercise artifact.
154
+ 28. Restore the artifact.
155
+ 29. Reopen its history to confirm that earlier versions remain available.
156
+
157
+ ## Handoff template
158
+
159
+ - **Goal:** the outcome the next person should achieve.
160
+ - **Current state:** what works and which version is being handed off.
161
+ - **Evidence:** test report IDs, related artifacts, commands, and observed results.
162
+ - **Decisions:** why the approach was chosen and relevant constraints.
163
+ - **Blockers:** known problems with concrete reproduction steps.
164
+ - **Next action:** one bounded task, its owner, and its completion check.
165
+
166
+ ## Administration practice
167
+
168
+ As an administrator, open **/admin/access** to inspect access mode and network rules. You can switch access mode or stage a network change.
169
+
170
+ 1. Before you stage a network change, keep your actual socket peer address in the allowed rules.
171
+ 2. Stage the change.
172
+ 3. Test the new route.
173
+ 4. Within 120 seconds, confirm the change or roll it back.
174
+
175
+ Unconfirmed changes remain pending across a restart. Before the next access, the server checks for expiry and rolls back an expired change. Proxy forwarding headers do not change the peer address used by the rule.
176
+
177
+ In private mode, use personal authentication for ordinary artifact reads and creation. Server-wide tokens do not authorize those operations. Administrator APIs have their own authorization rules. The local CLI is trusted administration. Before testing backup or retention, export a backup. Before a purge, review the purge plan.
178
+
179
+ ## Completion check
180
+
181
+ You can identify the approved version, read its evidence relation, explain the reviewer's effective permission, and prove the revoked link no longer opens. Record any remaining gap in the handoff.
182
+
183
+ Removing samples archives only the managed manuals. Reinstalling restores your edited manual versions without replacing them. Your separately created exercise artifacts remain yours.`
184
+ }
185
+ ];
@@ -0,0 +1,74 @@
1
+ import { archiveArtifact, createArtifact, ForbiddenError, openDatabase, restoreArtifact } from "./storage.js";
2
+ import { sampleCatalog } from "./sample-content.js";
3
+
4
+ const SAMPLE_NAMESPACE = "artifacty-manuals-v1";
5
+
6
+ function managedSamples(store, access) {
7
+ const db = openDatabase(store);
8
+ try {
9
+ const accountCount = db.prepare("SELECT COUNT(*) AS count FROM users").get().count;
10
+ if (access && accountCount) {
11
+ const user = db.prepare("SELECT role, active FROM users WHERE id = ?").get(access.userId || "");
12
+ if (!user?.active || user.role !== "admin") {
13
+ throw new ForbiddenError("Only active administrators may manage sample artifacts");
14
+ }
15
+ }
16
+ const scope = access?.userId || "local";
17
+ // The installation marker lives in the first immutable version: editing a
18
+ // manual must neither lose its managed identity nor overwrite user work.
19
+ const rows = db.prepare(`
20
+ SELECT artifacts.id, artifacts.archived_at, artifact_versions.metadata_json
21
+ FROM artifacts JOIN artifact_versions ON artifact_versions.artifact_id = artifacts.id
22
+ WHERE artifact_versions.version = 1
23
+ `).all();
24
+ const samples = rows.flatMap((row) => {
25
+ const marker = JSON.parse(row.metadata_json).artifactySample;
26
+ return marker?.namespace === SAMPLE_NAMESPACE && marker.scope === scope
27
+ ? [{ id: row.id, archivedAt: row.archived_at, key: marker.key }] : [];
28
+ });
29
+ return { samples, scope };
30
+ } finally {
31
+ db.close();
32
+ }
33
+ }
34
+
35
+ export async function installSamples(store, { access, audit, catalog = sampleCatalog } = {}) {
36
+ const { samples, scope } = managedSamples(store, access);
37
+ const result = { installed: 0, restored: 0, skipped: 0, artifactIds: [] };
38
+ for (const entry of catalog) {
39
+ const existing = samples.find((sample) => sample.key === entry.key);
40
+ if (existing) {
41
+ if (existing.archivedAt) {
42
+ await restoreArtifact(store, existing.id, { access, audit });
43
+ result.restored += 1;
44
+ } else {
45
+ result.skipped += 1;
46
+ }
47
+ result.artifactIds.push(existing.id);
48
+ continue;
49
+ }
50
+ const artifact = await createArtifact(store, {
51
+ ...entry,
52
+ sourceAgent: "generic",
53
+ access,
54
+ audit,
55
+ metadata: { ...entry.metadata, artifactySample: { namespace: SAMPLE_NAMESPACE, scope, key: entry.key } }
56
+ });
57
+ samples.push({ id: artifact.id, key: entry.key, archivedAt: null });
58
+ result.installed += 1;
59
+ result.artifactIds.push(artifact.id);
60
+ }
61
+ return result;
62
+ }
63
+
64
+ export async function removeSamples(store, { access, audit } = {}) {
65
+ const { samples } = managedSamples(store, access);
66
+ const result = { archived: 0, artifactIds: [] };
67
+ for (const sample of samples) {
68
+ if (sample.archivedAt) continue;
69
+ await archiveArtifact(store, sample.id, { access, audit, action: "samples-remove" });
70
+ result.archived += 1;
71
+ result.artifactIds.push(sample.id);
72
+ }
73
+ return result;
74
+ }