artifacty 0.11.0 → 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/mcp-server.js CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  ARTIFACT_TYPES,
11
11
  archiveArtifact,
12
12
  authenticateApiToken,
13
+ canReadArtifactEvent,
13
14
  createArtifact,
14
15
  createStore,
15
16
  getArtifact,
@@ -34,7 +35,7 @@ import {
34
35
  import { convertAgentArtifact } from "./lib/converters.js";
35
36
  import { createStructuredDiff, diffFormatFor, renderUnifiedDiffText, resolveDiffView } from "./lib/diff.js";
36
37
  import { createEmbeddingProvider } from "./lib/embeddings.js";
37
- import { EVENT_TYPES, eventVisibleTo, sanitizeEventForDelivery, subscribe } from "./lib/events.js";
38
+ import { EVENT_TYPES, sanitizeEventForDelivery, subscribe } from "./lib/events.js";
38
39
  import { inlineSchema } from "./lib/schemas.js";
39
40
  import { effectiveScopes, requireScope } from "./lib/security.js";
40
41
  import { resolvePublicBaseUrl } from "./lib/server-state.js";
@@ -896,6 +897,7 @@ async function callTool(context, name, args) {
896
897
  ...converted,
897
898
  allowSecrets: args.allowSecrets,
898
899
  auditAction: "import",
900
+ access: await mcpAccessContext(context),
899
901
  audit: mcpAuditContext(context)
900
902
  });
901
903
  return toolResult({
@@ -1081,6 +1083,7 @@ async function callTool(context, name, args) {
1081
1083
  activeWaits += 1;
1082
1084
  try {
1083
1085
  return toolResult(await waitForEvent({
1086
+ store: context.store,
1084
1087
  artifactId: args.artifactId,
1085
1088
  tag: args.tag,
1086
1089
  type: args.type,
@@ -1294,6 +1297,7 @@ async function createNativeArtifact(context, args) {
1294
1297
  allowSecrets: args.allowSecrets,
1295
1298
  visibility: args.visibility,
1296
1299
  relations: args.relations,
1300
+ access: await mcpAccessContext(context),
1297
1301
  audit: mcpAuditContext(context)
1298
1302
  });
1299
1303
  }
@@ -1468,7 +1472,7 @@ function parseArtifactResourceUri(uri) {
1468
1472
  // (stdio or one streamable-HTTP request) because it never outlives the
1469
1473
  // single tool call: it subscribes, waits, and always unsubscribes before
1470
1474
  // returning, regardless of whether a match arrived.
1471
- function waitForEvent({ artifactId, tag, type, timeoutMs, access } = {}) {
1475
+ function waitForEvent({ store, artifactId, tag, type, timeoutMs, access } = {}) {
1472
1476
  const filter = {};
1473
1477
  if (artifactId) filter.artifactId = artifactId;
1474
1478
  if (tag) filter.tag = tag;
@@ -1489,9 +1493,14 @@ function waitForEvent({ artifactId, tag, type, timeoutMs, access } = {}) {
1489
1493
  // A matching event for a private artifact the caller cannot see must
1490
1494
  // not resolve or leak the wait: keep waiting (silently) until a visible
1491
1495
  // match arrives or the timeout fires.
1492
- const unsubscribe = subscribe(filter, (event) => {
1496
+ const unsubscribe = subscribe(filter, async (event) => {
1497
+ if (settled) return;
1498
+ try {
1499
+ if (!await canReadArtifactEvent(store, event, access)) return;
1500
+ } catch {
1501
+ return;
1502
+ }
1493
1503
  if (settled) return;
1494
- if (!eventVisibleTo(event, access)) return;
1495
1504
  settled = true;
1496
1505
  clearTimeout(timer);
1497
1506
  unsubscribe();
@@ -1529,10 +1538,13 @@ async function subscribeResource(context, uri) {
1529
1538
  }
1530
1539
  if (!context.subscriptions.has(uri)) {
1531
1540
  const access = await mcpAccessContext(context);
1532
- const unsubscribe = subscribe(filter, (event) => {
1533
- if (!eventVisibleTo(event, access)) {
1541
+ const unsubscribe = subscribe(filter, async (event) => {
1542
+ try {
1543
+ if (!await canReadArtifactEvent(context.store, event, access)) return;
1544
+ } catch {
1534
1545
  return;
1535
1546
  }
1547
+ if (!context.subscriptions.has(uri)) return;
1536
1548
  context.notify({
1537
1549
  jsonrpc: "2.0",
1538
1550
  method: "notifications/resources/updated",
package/src/server.js CHANGED
@@ -25,6 +25,20 @@ import {
25
25
  deleteSavedView,
26
26
  deleteWebhook,
27
27
  getArtifact,
28
+ getAccessMode,
29
+ canReadArtifactEvent,
30
+ setAccessMode,
31
+ listTeams,
32
+ createTeam,
33
+ setTeamMembers,
34
+ getArtifactSharing,
35
+ getArtifactAccessReport,
36
+ getArtifactPermission,
37
+ previewAccessMode,
38
+ listNotifications,
39
+ markNotificationRead,
40
+ markAllNotificationsRead,
41
+ setArtifactSharing,
28
42
  getSessionUser,
29
43
  getWebhook,
30
44
  importUsersFromCsv,
@@ -59,12 +73,15 @@ import {
59
73
  VersionConflictError,
60
74
  verifyUserPassword
61
75
  } from "./lib/storage.js";
76
+ import { assertNetworkAccess, getNetworkPolicy, stageNetworkPolicy, getPendingNetworkPolicy, confirmNetworkPolicy, rollbackNetworkPolicy } from "./lib/network-policy.js";
77
+ import { getShareLinkPolicy, setShareLinkPolicy, createShareLink, listShareLinks, revokeShareLink, resolveShareLink } from "./lib/share-links.js";
78
+ import { installSamples, removeSamples } from "./lib/samples.js";
62
79
  import { MAX_BACKUP_BYTES, buildStoreBackup, exportStoreToString, importStoreBundle, importStoreFromString } from "./lib/backup.js";
63
80
  import { convertAgentArtifact, decodeMediaContent, mediaContentType } from "./lib/converters.js";
64
81
  import { createLineDiff, createStructuredDiff, diffFormatFor, resolveDiffView } from "./lib/diff.js";
65
82
  import { EDITOR_CLIENT_PATH, VIEWER_CLIENT_PATH, editorClientFilePath, editorVendorPath, viewerClientFilePath } from "./lib/editor-assets.js";
66
83
  import { createEmbeddingProvider } from "./lib/embeddings.js";
67
- import { EVENT_TYPES, eventVisibleTo, publish, sanitizeEventForDelivery, subscribe } from "./lib/events.js";
84
+ import { EVENT_TYPES, publish, sanitizeEventForDelivery, subscribe } from "./lib/events.js";
68
85
  import { localeFromBodyOrUrl, localeFromUrl, localizedHref } from "./lib/i18n.js";
69
86
  import { paginationJson } from "./lib/listing.js";
70
87
  import { buildOpenApiDocument, renderApiDocsHtml } from "./lib/openapi.js";
@@ -92,6 +109,10 @@ import {
92
109
  renderArtifactPage,
93
110
  renderAccountPage,
94
111
  renderAdminBackupPage,
112
+ renderAdminAccessPage,
113
+ renderArtifactSharingPage,
114
+ renderSharedArtifactPage,
115
+ renderInboxPage,
95
116
  renderAdminArtifactVersionsPage,
96
117
  renderAdminRetentionPage,
97
118
  renderAdminUsersPage,
@@ -283,6 +304,7 @@ function closeServer(server) {
283
304
  }
284
305
 
285
306
  export async function handleRequest({ request, response, store, host, port, security = securityConfig(), mcpHttp = false }) {
307
+ await assertNetworkAccess(store, request.socket.remoteAddress);
286
308
  const url = new URL(request.url, `http://${request.headers.host || `${host}:${port}`}`);
287
309
  const pathname = decodeURIComponent(url.pathname);
288
310
  const baseUrl = `http://${host}:${port}`;
@@ -292,6 +314,35 @@ export async function handleRequest({ request, response, store, host, port, secu
292
314
  const headOnly = request.method === "HEAD";
293
315
  const method = headOnly ? "GET" : request.method;
294
316
 
317
+ const publicShareMatch = /^\/s\/([^/]+)(\/raw)?$/.exec(pathname);
318
+ if (publicShareMatch && method === "GET") {
319
+ const shared = await resolveShareLink(store, publicShareMatch[1]);
320
+ response.setHeader("referrer-policy", "no-referrer");
321
+ response.setHeader("cache-control", "no-store");
322
+ if (publicShareMatch[2]) {
323
+ const fileName = url.searchParams.get("file");
324
+ const bundleFile = fileName ? readBundleFile(shared.artifact, shared.content, fileName) : null;
325
+ if (fileName && !bundleFile) return sendJson(response, { error: "Not found" }, 404, headOnly);
326
+ const raw = bundleFile || rawArtifactResponse(shared.artifact);
327
+ response.writeHead(200, {
328
+ "content-type": raw.contentType,
329
+ "cache-control": "no-store",
330
+ "content-security-policy": "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:; frame-ancestors 'self'",
331
+ "x-content-type-options": "nosniff",
332
+ ...(bundleFile ? { "content-disposition": buildContentDisposition(bundleFile.contentType === "application/pdf" ? "inline" : "attachment", bundleFile.fileName) } : {})
333
+ });
334
+ response.end(headOnly ? undefined : raw.body);
335
+ return;
336
+ }
337
+ // Pass only display fields, never the source id, ownership, metadata, or history.
338
+ return sendHtml(response, renderSharedArtifactPage({
339
+ artifact: { title: shared.artifact.title, artifactType: shared.artifact.artifactType },
340
+ version: { version: shared.version.version, format: shared.version.format, contentType: shared.version.contentType },
341
+ content: shared.content, baseUrl, locale,
342
+ sharePath: `/s/${encodeURIComponent(publicShareMatch[1])}`
343
+ }), 200, headOnly);
344
+ }
345
+
295
346
  if (method === "GET" && pathname === EDITOR_CLIENT_PATH) {
296
347
  return sendJavaScriptFile(response, editorClientFilePath(PACKAGE_ROOT), headOnly, request);
297
348
  }
@@ -490,6 +541,119 @@ export async function handleRequest({ request, response, store, host, port, secu
490
541
  return sendRedirect(response, "/account");
491
542
  }
492
543
 
544
+ const browserAccess = accessContext(request, currentUser);
545
+ const browserAudit = { surface: "web", actor: currentUser?.email, userId: currentUser?.id };
546
+ const adminAccessPage = async (extra = {}) => renderAdminAccessPage({
547
+ baseUrl, user: currentUser, mode: await getAccessMode(store),
548
+ teams: await listTeams(store), users: await listUsers(store),
549
+ networkPolicy: await getNetworkPolicy(store), pendingNetworkPolicy: await getPendingNetworkPolicy(store),
550
+ shareLinkPolicy: await getShareLinkPolicy(store), currentIp: request.socket.remoteAddress,
551
+ locale, currentPath, ...extra
552
+ });
553
+ const adminAccessPaths = ["/admin/access", "/admin/access/preview", "/admin/access/network", "/admin/access/network/confirm", "/admin/access/network/rollback", "/admin/access/share-links", "/admin/teams", "/admin/samples/install", "/admin/samples/remove"];
554
+ if (adminAccessPaths.includes(pathname) || /^\/admin\/teams\/[^/]+\/members$/.test(pathname)) {
555
+ if (!currentUser) return sendRedirect(response, "/login");
556
+ requireAdmin(currentUser);
557
+ if (method === "GET" && pathname === "/admin/access") {
558
+ return sendHtml(response, await adminAccessPage(), 200, headOnly);
559
+ }
560
+ if (method === "POST") {
561
+ assertLocalOrigin(request);
562
+ request.artifactyAuth = await requireBrowserWriteAuth({ store, request, url, config: security, currentUser });
563
+ await enforceRateLimit({ store, host, request, bucket: "write" });
564
+ const body = await readFormBody(request);
565
+ const options = { access: browserAccess, audit: browserAudit, remoteAddress: request.socket.remoteAddress };
566
+ if (pathname === "/admin/access/preview") {
567
+ return sendHtml(response, await adminAccessPage({ preview: await previewAccessMode(store, body.mode, options) }), 200, headOnly);
568
+ } else if (pathname === "/admin/access") {
569
+ await setAccessMode(store, body.mode, options);
570
+ } else if (pathname === "/admin/access/network") {
571
+ await stageNetworkPolicy(store, { allowedCidrs: body.allowedCidrs || "" }, options);
572
+ } else if (pathname === "/admin/access/network/confirm") {
573
+ await confirmNetworkPolicy(store, body.id, options);
574
+ } else if (pathname === "/admin/access/network/rollback") {
575
+ await rollbackNetworkPolicy(store, body.id, options);
576
+ } else if (pathname === "/admin/access/share-links") {
577
+ await setShareLinkPolicy(store, { enabledInPrivate: body.enabledInPrivate === "on" || body.enabledInPrivate === "true" }, options);
578
+ } else if (pathname === "/admin/samples/install") {
579
+ await installSamples(store, options);
580
+ } else if (pathname === "/admin/samples/remove") {
581
+ await removeSamples(store, options);
582
+ } else if (pathname === "/admin/teams") {
583
+ await createTeam(store, { name: body.name }, options);
584
+ } else {
585
+ await setTeamMembers(store, pathname.split("/")[3], body.userIds || [], options);
586
+ }
587
+ return sendRedirect(response, localizedHref("/admin/access", localeFromBodyOrUrl(body, url)));
588
+ }
589
+ }
590
+
591
+ const inboxReadMatch = /^\/inbox\/([^/]+)\/read$/.exec(pathname);
592
+ if (pathname === "/inbox" || pathname === "/inbox/read-all" || inboxReadMatch) {
593
+ if (!currentUser) return sendRedirect(response, "/login");
594
+ if (method === "GET" && pathname === "/inbox") {
595
+ return sendHtml(response, renderInboxPage({
596
+ baseUrl, user: currentUser, ...await listNotifications(store, { access: browserAccess }), locale, currentPath
597
+ }), 200, headOnly);
598
+ }
599
+ if (method === "POST" && (inboxReadMatch || pathname === "/inbox/read-all")) {
600
+ assertLocalOrigin(request);
601
+ request.artifactyAuth = await requireBrowserWriteAuth({ store, request, url, config: security, currentUser });
602
+ await enforceRateLimit({ store, host, request, bucket: "write" });
603
+ const body = await readFormBody(request);
604
+ if (inboxReadMatch) await markNotificationRead(store, inboxReadMatch[1], { access: browserAccess });
605
+ else await markAllNotificationsRead(store, { access: browserAccess });
606
+ return sendRedirect(response, localizedHref("/inbox", localeFromBodyOrUrl(body, url)));
607
+ }
608
+ }
609
+
610
+ const sharingMatch = /^\/artifacts\/([^/]+)\/(sharing|share-links)(?:\/([^/]+)\/revoke)?$/.exec(pathname);
611
+ if (sharingMatch && (method === "GET" || method === "POST")) {
612
+ if (!currentUser) return sendRedirect(response, "/login");
613
+ const access = browserAccess;
614
+ const artifact = await getArtifact(store, sharingMatch[1], { access });
615
+ if (!artifact) return sendJson(response, { error: "Not found" }, 404, headOnly);
616
+ if (currentUser.role !== "admin" && artifact.ownerUserId !== currentUser.id) {
617
+ throw Object.assign(new Error("Only the owner or an administrator can manage sharing"), { statusCode: 403 });
618
+ }
619
+ let createdShareLink = null;
620
+ if (method === "POST") {
621
+ assertLocalOrigin(request);
622
+ request.artifactyAuth = await requireBrowserWriteAuth({ store, request, url, config: security, currentUser });
623
+ await enforceRateLimit({ store, host, request, bucket: "write" });
624
+ const body = await readFormBody(request);
625
+ const options = { access, audit: browserAudit };
626
+ if (sharingMatch[2] === "sharing") {
627
+ const permissions = (prefix) => Object.fromEntries(Object.entries(body).filter(([key]) => key.startsWith(prefix)).map(([key, value]) => [key.slice(prefix.length), value]));
628
+ await setArtifactSharing(store, sharingMatch[1], {
629
+ userIds: body.userIds || [], teamIds: body.teamIds || [],
630
+ userPermissions: permissions("userPermission:"), teamPermissions: permissions("teamPermission:")
631
+ }, options);
632
+ } else if (sharingMatch[3]) {
633
+ await revokeShareLink(store, sharingMatch[1], sharingMatch[3], options);
634
+ } else {
635
+ const days = body.days === undefined ? 7 : Number(body.days);
636
+ if (!Number.isInteger(days) || days < 1 || days > 365) {
637
+ throw Object.assign(new Error("Expiry must be between 1 and 365 days"), { statusCode: 400 });
638
+ }
639
+ const version = !body.version || body.version === "latest" ? null : Number(body.version);
640
+ createdShareLink = await createShareLink(store, sharingMatch[1], {
641
+ version, expiresAt: body.expiresAt || new Date(Date.now() + days * 86400000).toISOString()
642
+ }, options);
643
+ }
644
+ if (!createdShareLink) return sendRedirect(response, localizedHref(`/artifacts/${encodeURIComponent(sharingMatch[1])}/sharing`, localeFromBodyOrUrl(body, url)));
645
+ }
646
+ return sendHtml(response, renderArtifactSharingPage({
647
+ baseUrl, user: currentUser, artifact,
648
+ sharing: await getArtifactSharing(store, sharingMatch[1], { access, includePermissions: true }),
649
+ accessReport: await getArtifactAccessReport(store, sharingMatch[1], { access }),
650
+ networkPolicy: await getNetworkPolicy(store),
651
+ shareLinks: await listShareLinks(store, sharingMatch[1], { access }),
652
+ createdShareLink, shareLinkPolicy: await getShareLinkPolicy(store), mode: await getAccessMode(store),
653
+ users: await listUsers(store), teams: await listTeams(store), locale, currentPath
654
+ }), 200, headOnly);
655
+ }
656
+
493
657
  if (method === "GET" && pathname === "/admin/backup") {
494
658
  if (!currentUser) {
495
659
  return sendRedirect(response, "/login");
@@ -804,6 +968,11 @@ export async function handleRequest({ request, response, store, host, port, secu
804
968
  return sendJson(response, { ok: true, name: "artifacty", store: store.home }, 200, headOnly);
805
969
  }
806
970
 
971
+ if (!currentUser && await getAccessMode(store) === "private" &&
972
+ (["/", "/new", "/import"].includes(pathname) || pathname.startsWith("/artifacts/"))) {
973
+ return sendRedirect(response, localizedHref("/login", locale));
974
+ }
975
+
807
976
  if (method === "GET" && pathname === "/") {
808
977
  const access = accessContext(request, currentUser);
809
978
  const resolved = await resolveListFilters(store, url, access);
@@ -885,6 +1054,7 @@ export async function handleRequest({ request, response, store, host, port, secu
885
1054
  request.artifactyAuth = await requireBrowserWriteAuth({ store, request, url, body, config: security, currentUser });
886
1055
  await enforceRateLimit({ store, host, request, bucket: "write" });
887
1056
  const artifact = await createArtifact(store, {
1057
+ access: accessContext(request, currentUser),
888
1058
  title: body.title,
889
1059
  content: body.content,
890
1060
  format: body.format,
@@ -921,6 +1091,7 @@ export async function handleRequest({ request, response, store, host, port, secu
921
1091
  }
922
1092
  });
923
1093
  const artifact = await createArtifact(store, {
1094
+ access: accessContext(request, currentUser),
924
1095
  ...converted,
925
1096
  visibility: body.visibility || undefined,
926
1097
  auditAction: "import",
@@ -1135,6 +1306,7 @@ export async function handleRequest({ request, response, store, host, port, secu
1135
1306
  await enforceRateLimit({ store, host, request, bucket: "write" });
1136
1307
  const body = await readJsonBody(request);
1137
1308
  const artifact = await createArtifact(store, {
1309
+ access: accessContext(request, currentUser),
1138
1310
  ...pickCreateArtifactInput(body),
1139
1311
  audit: auditContext(request, "http-api")
1140
1312
  });
@@ -1148,6 +1320,7 @@ export async function handleRequest({ request, response, store, host, port, secu
1148
1320
  const body = await readJsonBody(request);
1149
1321
  const converted = convertAgentArtifact(body);
1150
1322
  const artifact = await createArtifact(store, {
1323
+ access: accessContext(request, currentUser),
1151
1324
  ...pickCreateArtifactInput(converted),
1152
1325
  auditAction: "import",
1153
1326
  audit: auditContext(request, "http-api")
@@ -1622,6 +1795,7 @@ export async function handleRequest({ request, response, store, host, port, secu
1622
1795
  version: artifact.version,
1623
1796
  content: artifact.content,
1624
1797
  comments,
1798
+ permission: await getArtifactPermission(store, artifactMatch[1], { access: accessContext(request, currentUser) }),
1625
1799
  baseUrl,
1626
1800
  authToken,
1627
1801
  locale,
@@ -1717,7 +1891,12 @@ export async function readJsonBody(request, limitBytes = MAX_ARTIFACT_BYTES + 10
1717
1891
  export async function readFormBody(request, limitBytes = MAX_ARTIFACT_BYTES + 1024) {
1718
1892
  const raw = await readBody(request, limitBytes);
1719
1893
  const params = new URLSearchParams(raw);
1720
- return Object.fromEntries(params.entries());
1894
+ const body = Object.fromEntries(params.entries());
1895
+ // Sharing checkboxes submit repeated keys; scalar form fields keep their existing shape.
1896
+ for (const key of ["userIds", "teamIds"]) {
1897
+ if (params.has(key)) body[key] = params.getAll(key);
1898
+ }
1899
+ return body;
1721
1900
  }
1722
1901
 
1723
1902
  export async function readBody(request, limitBytes) {
@@ -2215,14 +2394,22 @@ async function handleEventStream({ request, response, store, filter, access }) {
2215
2394
  }
2216
2395
 
2217
2396
  unsubscribe = subscribe(filter, (event) => {
2218
- if (!eventVisibleTo(event, access)) {
2219
- return;
2220
- }
2221
- writeSseEvent(response, event);
2397
+ // Recheck persisted permissions: sharing and membership can change while connected.
2398
+ const readable = canReadArtifactEvent(store, event, access).catch(() => false);
2399
+ readable.then(async (allowed) => {
2400
+ await assertNetworkAccess(store, request.socket.remoteAddress);
2401
+ if (allowed && !closed) writeSseEvent(response, event);
2402
+ }).catch(() => { cleanup(); response.end(); });
2222
2403
  });
2223
2404
 
2224
2405
  heartbeat = setInterval(() => {
2225
- response.write(": heartbeat\n\n");
2406
+ try {
2407
+ assertNetworkAccess(store, request.socket.remoteAddress);
2408
+ if (!closed) response.write(": heartbeat\n\n");
2409
+ } catch {
2410
+ cleanup();
2411
+ response.end();
2412
+ }
2226
2413
  }, SSE_HEARTBEAT_MS);
2227
2414
  heartbeat.unref?.();
2228
2415