artifacty 0.7.0 → 0.9.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/server.js CHANGED
@@ -6,29 +6,45 @@ import { URL } from "node:url";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import {
8
8
  archiveArtifact,
9
+ authenticateApiToken,
10
+ countUsers,
11
+ createApiToken,
9
12
  createArtifact,
13
+ createSession,
10
14
  createStore,
15
+ createUser,
11
16
  getArtifact,
17
+ getSessionUser,
18
+ listApiTokens,
12
19
  listArtifactsPage,
13
20
  listAuditEvents,
21
+ listUsers,
14
22
  MAX_ARTIFACT_BYTES,
23
+ revokeApiToken,
24
+ revokeSession,
15
25
  restoreArtifact,
16
- updateArtifact
26
+ setUserActive,
27
+ updateArtifact,
28
+ verifyUserPassword
17
29
  } from "./lib/storage.js";
18
30
  import { convertAgentArtifact } from "./lib/converters.js";
19
31
  import { createLineDiff } from "./lib/diff.js";
20
32
  import { EDITOR_CLIENT_PATH, VIEWER_CLIENT_PATH, editorClientFilePath, editorVendorPath, viewerClientFilePath } from "./lib/editor-assets.js";
21
33
  import { localeFromBodyOrUrl, localeFromUrl, localizedHref } from "./lib/i18n.js";
22
- import { exposureWarning, requireToken, securityConfig, validateServerExposure } from "./lib/security.js";
34
+ import { exposureWarning, requestToken, securityConfig, tokensEqual, validateServerExposure } from "./lib/security.js";
23
35
  import { writeServerState } from "./lib/server-state.js";
24
36
  import { generateToken } from "./lib/token.js";
37
+ import { createMcpJsonRpcHandler } from "./mcp-server.js";
25
38
  import {
26
39
  renderArtifactFormPage,
27
40
  renderArtifactPage,
41
+ renderAccountPage,
42
+ renderAdminUsersPage,
28
43
  renderReactFramePage,
29
44
  renderDashboard,
30
45
  renderDiffPage,
31
46
  renderImportArtifactPage,
47
+ renderLoginPage,
32
48
  renderNewArtifactPage
33
49
  } from "./lib/render.js";
34
50
 
@@ -46,6 +62,7 @@ export async function startServer(options = {}) {
46
62
  (!explicitPort && options.portFallback !== false);
47
63
  const store = createStore({ home: options.home });
48
64
  const security = securityConfig(options);
65
+ const mcpHttp = Boolean(options.mcpHttp || process.env.ARTIFACTY_MCP_HTTP === "true");
49
66
  validateServerExposure({ host, config: security });
50
67
 
51
68
  const { server, actualPort } = await listenWithFallback({
@@ -54,7 +71,7 @@ export async function startServer(options = {}) {
54
71
  allowPortFallback,
55
72
  createServer(port) {
56
73
  const candidateServer = http.createServer((request, response) => {
57
- handleRequest({ request, response, store, host, port: candidateServer.address()?.port || port, security }).catch((error) => {
74
+ handleRequest({ request, response, store, host, port: candidateServer.address()?.port || port, security, mcpHttp }).catch((error) => {
58
75
  sendError(response, error);
59
76
  });
60
77
  });
@@ -138,7 +155,7 @@ function closeServer(server) {
138
155
  });
139
156
  }
140
157
 
141
- export async function handleRequest({ request, response, store, host, port, security = securityConfig() }) {
158
+ export async function handleRequest({ request, response, store, host, port, security = securityConfig(), mcpHttp = false }) {
142
159
  const url = new URL(request.url, `http://${request.headers.host || `${host}:${port}`}`);
143
160
  const pathname = decodeURIComponent(url.pathname);
144
161
  const baseUrl = `http://${host}:${port}`;
@@ -165,8 +182,156 @@ export async function handleRequest({ request, response, store, host, port, secu
165
182
  return sendJavaScriptFile(response, vendorPath, headOnly, request);
166
183
  }
167
184
 
185
+ const userCount = await countUsers(store);
186
+ const currentUser = userCount > 0 ? await sessionUserFromRequest(store, request) : null;
187
+
188
+ if (method === "GET" && pathname === "/login") {
189
+ return sendHtml(response, renderLoginPage({
190
+ baseUrl,
191
+ setup: userCount === 0,
192
+ locale,
193
+ currentPath
194
+ }), 200, headOnly);
195
+ }
196
+
197
+ if (method === "POST" && pathname === "/login") {
198
+ const body = await readFormBody(request);
199
+ const email = body.email;
200
+ const password = body.password;
201
+ const user = userCount === 0
202
+ ? await createUser(store, {
203
+ email,
204
+ name: body.name || email,
205
+ role: "admin",
206
+ password
207
+ })
208
+ : await verifyUserPassword(store, email, password);
209
+ if (!user) {
210
+ return sendHtml(response, renderLoginPage({
211
+ baseUrl,
212
+ setup: false,
213
+ error: "Invalid email or password.",
214
+ locale,
215
+ currentPath
216
+ }), 401);
217
+ }
218
+ const session = await createSession(store, user.id);
219
+ return sendRedirect(response, "/account", {
220
+ "set-cookie": sessionCookie(session.token)
221
+ });
222
+ }
223
+
224
+ if (method === "POST" && pathname === "/logout") {
225
+ await revokeSession(store, sessionTokenFromRequest(request));
226
+ return sendRedirect(response, "/login", {
227
+ "set-cookie": clearSessionCookie()
228
+ });
229
+ }
230
+
231
+ if (method === "GET" && pathname === "/account") {
232
+ if (!currentUser) {
233
+ return sendRedirect(response, "/login");
234
+ }
235
+ const tokens = await listApiTokens(store, currentUser.id);
236
+ return sendHtml(response, renderAccountPage({
237
+ baseUrl,
238
+ user: currentUser,
239
+ tokens,
240
+ locale,
241
+ currentPath
242
+ }), 200, headOnly);
243
+ }
244
+
245
+ if (method === "POST" && pathname === "/account/tokens") {
246
+ if (!currentUser) {
247
+ return sendRedirect(response, "/login");
248
+ }
249
+ const body = await readFormBody(request);
250
+ const created = await createApiToken(store, currentUser.id, {
251
+ name: body.name
252
+ });
253
+ const tokens = await listApiTokens(store, currentUser.id);
254
+ return sendHtml(response, renderAccountPage({
255
+ baseUrl,
256
+ user: currentUser,
257
+ tokens,
258
+ createdToken: created.token,
259
+ locale,
260
+ currentPath: "/account"
261
+ }));
262
+ }
263
+
264
+ const tokenRevokeMatch = /^\/account\/tokens\/([^/]+)\/revoke$/.exec(pathname);
265
+ if (tokenRevokeMatch && method === "POST") {
266
+ if (!currentUser) {
267
+ return sendRedirect(response, "/login");
268
+ }
269
+ await revokeApiToken(store, tokenRevokeMatch[1], currentUser.id);
270
+ return sendRedirect(response, "/account");
271
+ }
272
+
273
+ if (method === "GET" && pathname === "/admin/users") {
274
+ if (!currentUser) {
275
+ return sendRedirect(response, "/login");
276
+ }
277
+ requireAdmin(currentUser);
278
+ return sendHtml(response, renderAdminUsersPage({
279
+ baseUrl,
280
+ user: currentUser,
281
+ users: await listUsers(store),
282
+ locale,
283
+ currentPath
284
+ }), 200, headOnly);
285
+ }
286
+
287
+ if (method === "POST" && pathname === "/admin/users") {
288
+ if (!currentUser) {
289
+ return sendRedirect(response, "/login");
290
+ }
291
+ requireAdmin(currentUser);
292
+ const body = await readFormBody(request);
293
+ await createUser(store, {
294
+ email: body.email,
295
+ name: body.name || body.email,
296
+ role: body.role || "user",
297
+ password: body.password
298
+ });
299
+ return sendRedirect(response, "/admin/users");
300
+ }
301
+
302
+ const userActiveMatch = /^\/admin\/users\/([^/]+)\/(enable|disable)$/.exec(pathname);
303
+ if (userActiveMatch && method === "POST") {
304
+ if (!currentUser) {
305
+ return sendRedirect(response, "/login");
306
+ }
307
+ requireAdmin(currentUser);
308
+ if (userActiveMatch[1] === currentUser.id && userActiveMatch[2] === "disable") {
309
+ throw Object.assign(new Error("Admins cannot disable their own account"), {
310
+ statusCode: 400,
311
+ code: "SELF_DISABLE_BLOCKED"
312
+ });
313
+ }
314
+ await setUserActive(store, userActiveMatch[1], userActiveMatch[2] === "enable");
315
+ return sendRedirect(response, "/admin/users");
316
+ }
317
+
318
+ if (pathname === "/mcp") {
319
+ if (!mcpHttp) {
320
+ return sendJson(response, { error: "Not found" }, 404, headOnly);
321
+ }
322
+ request.artifactyAuth = await requireRequestAuth({ store, request, url, config: security });
323
+ return handleMcpHttpRequest({
324
+ request,
325
+ response,
326
+ store,
327
+ baseUrl,
328
+ headOnly,
329
+ method
330
+ });
331
+ }
332
+
168
333
  if (pathname.startsWith("/api/")) {
169
- requireToken({ request, url, config: security });
334
+ request.artifactyAuth = await requireRequestAuth({ store, request, url, config: security });
170
335
  }
171
336
 
172
337
  if (method === "GET" && pathname === "/health") {
@@ -190,7 +355,7 @@ export async function handleRequest({ request, response, store, host, port, secu
190
355
  limit: filters.limit,
191
356
  offset: filters.offset
192
357
  });
193
- return sendHtml(response, renderDashboard({ artifacts: page.artifacts, baseUrl, filters, pagination: page, locale, currentPath }), 200, headOnly);
358
+ return sendHtml(response, renderDashboard({ artifacts: page.artifacts, baseUrl, filters, pagination: page, locale, currentPath, user: currentUser }), 200, headOnly);
194
359
  }
195
360
 
196
361
  if (method === "GET" && pathname === "/new") {
@@ -201,7 +366,7 @@ export async function handleRequest({ request, response, store, host, port, secu
201
366
  assertLocalOrigin(request);
202
367
  const body = await readFormBody(request);
203
368
  const bodyLocale = localeFromBodyOrUrl(body, url);
204
- requireToken({ request, url, body, config: security });
369
+ request.artifactyAuth = await requireBrowserWriteAuth({ store, request, url, body, config: security, currentUser });
205
370
  const artifact = await createArtifact(store, {
206
371
  title: body.title,
207
372
  content: body.content,
@@ -225,7 +390,7 @@ export async function handleRequest({ request, response, store, host, port, secu
225
390
  assertLocalOrigin(request);
226
391
  const body = await readFormBody(request);
227
392
  const bodyLocale = localeFromBodyOrUrl(body, url);
228
- requireToken({ request, url, body, config: security });
393
+ request.artifactyAuth = await requireBrowserWriteAuth({ store, request, url, body, config: security, currentUser });
229
394
  const converted = convertAgentArtifact({
230
395
  agent: body.agent,
231
396
  title: body.title,
@@ -314,7 +479,7 @@ export async function handleRequest({ request, response, store, host, port, secu
314
479
  assertLocalOrigin(request);
315
480
  const body = await readFormBody(request);
316
481
  const bodyLocale = localeFromBodyOrUrl(body, url);
317
- requireToken({ request, url, body, config: security });
482
+ request.artifactyAuth = await requireBrowserWriteAuth({ store, request, url, body, config: security, currentUser });
318
483
  const artifact = await updateArtifact(store, editMatch[1], {
319
484
  title: body.title,
320
485
  content: body.content,
@@ -357,7 +522,7 @@ export async function handleRequest({ request, response, store, host, port, secu
357
522
  assertLocalOrigin(request);
358
523
  const body = await readFormBody(request);
359
524
  const bodyLocale = localeFromBodyOrUrl(body, url);
360
- requireToken({ request, url, body, config: security });
525
+ request.artifactyAuth = await requireBrowserWriteAuth({ store, request, url, body, config: security, currentUser });
361
526
  const artifact = archiveMatch[2] === "archive"
362
527
  ? await archiveArtifact(store, archiveMatch[1], { audit: auditContext(request, "web") })
363
528
  : await restoreArtifact(store, archiveMatch[1], { audit: auditContext(request, "web") });
@@ -452,6 +617,56 @@ export function decorateArtifactUrls(artifact, baseUrl) {
452
617
  };
453
618
  }
454
619
 
620
+ async function handleMcpHttpRequest({ request, response, store, baseUrl, headOnly, method }) {
621
+ if (method === "OPTIONS") {
622
+ response.writeHead(204, {
623
+ allow: "POST, OPTIONS",
624
+ "cache-control": "no-store"
625
+ });
626
+ response.end();
627
+ return;
628
+ }
629
+
630
+ if (method !== "POST") {
631
+ response.writeHead(405, {
632
+ allow: "POST, OPTIONS",
633
+ "content-type": "application/json; charset=utf-8",
634
+ "cache-control": "no-store",
635
+ "x-content-type-options": "nosniff"
636
+ });
637
+ response.end(headOnly ? undefined : `${JSON.stringify({ error: "MCP endpoint accepts POST JSON-RPC requests" }, null, 2)}\n`);
638
+ return;
639
+ }
640
+
641
+ const body = await readJsonBody(request);
642
+ const messages = Array.isArray(body) ? body : [body];
643
+ const handler = createMcpJsonRpcHandler({
644
+ store,
645
+ transport: "streamable-http",
646
+ publicBaseUrl: baseUrl,
647
+ serverCommand: `${baseUrl}/mcp`,
648
+ browserCommand: baseUrl,
649
+ auditContext: () => auditContext(request, "mcp-http")
650
+ });
651
+ const responses = [];
652
+ for (const message of messages) {
653
+ const jsonRpcResponse = await handler(message);
654
+ if (jsonRpcResponse) {
655
+ responses.push(jsonRpcResponse);
656
+ }
657
+ }
658
+
659
+ if (responses.length === 0) {
660
+ response.writeHead(202, {
661
+ "cache-control": "no-store"
662
+ });
663
+ response.end();
664
+ return;
665
+ }
666
+
667
+ sendJson(response, Array.isArray(body) ? responses : responses[0], 200, headOnly);
668
+ }
669
+
455
670
  function paginationJson(page) {
456
671
  return {
457
672
  total: page.total,
@@ -622,10 +837,11 @@ function javascriptCorsHeaders(request) {
622
837
  };
623
838
  }
624
839
 
625
- export function sendRedirect(response, location) {
840
+ export function sendRedirect(response, location, headers = {}) {
626
841
  response.writeHead(303, {
627
842
  location,
628
- "cache-control": "no-store"
843
+ "cache-control": "no-store",
844
+ ...headers
629
845
  });
630
846
  response.end();
631
847
  }
@@ -653,12 +869,98 @@ function splitTags(value) {
653
869
  }
654
870
 
655
871
  function auditContext(request, surface) {
872
+ const auth = request.artifactyAuth;
656
873
  return {
657
874
  surface,
658
- actor: request.headers["x-artifacty-actor"] || request.headers["user-agent"] || "unknown"
875
+ actor: auth?.actor || request.headers["x-artifacty-actor"] || request.headers["user-agent"] || "unknown",
876
+ userId: auth?.user?.id || null,
877
+ tokenId: auth?.tokenId || null
659
878
  };
660
879
  }
661
880
 
881
+ async function requireRequestAuth({ store, request, url, body = {}, config }) {
882
+ const token = requestToken({ request, url, body });
883
+ if (config.apiToken && tokensEqual(token, config.apiToken)) {
884
+ return {
885
+ type: "global-token",
886
+ actor: request.headers["x-artifacty-actor"] || "artifacty-token",
887
+ role: "admin"
888
+ };
889
+ }
890
+
891
+ const tokenAuth = await authenticateApiToken(store, token);
892
+ if (tokenAuth) {
893
+ return tokenAuth;
894
+ }
895
+
896
+ const authRequired = Boolean(config.apiToken) || await countUsers(store) > 0;
897
+ if (!authRequired) {
898
+ return {
899
+ type: "anonymous",
900
+ actor: request.headers["x-artifacty-actor"] || request.headers["user-agent"] || "anonymous"
901
+ };
902
+ }
903
+
904
+ throw Object.assign(new Error("Artifacty authentication required"), {
905
+ code: "AUTH_REQUIRED",
906
+ statusCode: 401
907
+ });
908
+ }
909
+
910
+ async function requireBrowserWriteAuth({ store, request, url, body, config, currentUser }) {
911
+ if (currentUser) {
912
+ return {
913
+ type: "session",
914
+ actor: currentUser.email,
915
+ user: currentUser,
916
+ role: currentUser.role
917
+ };
918
+ }
919
+ return requireRequestAuth({ store, request, url, body, config });
920
+ }
921
+
922
+ function requireAdmin(user) {
923
+ if (user?.role !== "admin") {
924
+ throw Object.assign(new Error("Artifacty admin privileges required"), {
925
+ code: "ADMIN_REQUIRED",
926
+ statusCode: 403
927
+ });
928
+ }
929
+ }
930
+
931
+ async function sessionUserFromRequest(store, request) {
932
+ return getSessionUser(store, sessionTokenFromRequest(request));
933
+ }
934
+
935
+ function sessionTokenFromRequest(request) {
936
+ const cookies = parseCookies(request.headers.cookie || "");
937
+ return cookies.artifacty_session || "";
938
+ }
939
+
940
+ function parseCookies(header) {
941
+ const cookies = {};
942
+ for (const part of String(header || "").split(";")) {
943
+ const index = part.indexOf("=");
944
+ if (index === -1) {
945
+ continue;
946
+ }
947
+ const key = part.slice(0, index).trim();
948
+ const value = part.slice(index + 1).trim();
949
+ if (key) {
950
+ cookies[key] = decodeURIComponent(value);
951
+ }
952
+ }
953
+ return cookies;
954
+ }
955
+
956
+ function sessionCookie(token) {
957
+ return `artifacty_session=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${7 * 24 * 60 * 60}`;
958
+ }
959
+
960
+ function clearSessionCookie() {
961
+ return "artifacty_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0";
962
+ }
963
+
662
964
  function isMain(metaUrl) {
663
965
  return process.argv[1] && metaUrl === new URL(`file://${process.argv[1]}`).href;
664
966
  }
@@ -712,6 +1014,8 @@ function parseServerArgs(args) {
712
1014
  options.generateToken = true;
713
1015
  } else if (arg === "--allow-secrets") {
714
1016
  options.allowSecrets = true;
1017
+ } else if (arg === "--mcp-http") {
1018
+ options.mcpHttp = true;
715
1019
  }
716
1020
  }
717
1021
  return options;