artifacty 0.8.0 → 0.10.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,48 @@ import { URL } from "node:url";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import {
8
8
  archiveArtifact,
9
+ authenticateApiToken,
10
+ changeUserPassword,
11
+ countUsers,
12
+ createApiToken,
9
13
  createArtifact,
14
+ createSession,
10
15
  createStore,
16
+ createUser,
11
17
  getArtifact,
18
+ getSessionUser,
19
+ importUsersFromCsv,
20
+ listApiTokens,
12
21
  listArtifactsPage,
13
22
  listAuditEvents,
23
+ listUsers,
14
24
  MAX_ARTIFACT_BYTES,
25
+ revokeApiToken,
26
+ revokeSession,
15
27
  restoreArtifact,
16
- updateArtifact
28
+ setUserActive,
29
+ updateArtifact,
30
+ verifyUserPassword
17
31
  } from "./lib/storage.js";
18
32
  import { convertAgentArtifact } from "./lib/converters.js";
19
33
  import { createLineDiff } from "./lib/diff.js";
20
34
  import { EDITOR_CLIENT_PATH, VIEWER_CLIENT_PATH, editorClientFilePath, editorVendorPath, viewerClientFilePath } from "./lib/editor-assets.js";
21
35
  import { localeFromBodyOrUrl, localeFromUrl, localizedHref } from "./lib/i18n.js";
22
- import { exposureWarning, requireToken, securityConfig, validateServerExposure } from "./lib/security.js";
36
+ import { exposureWarning, requestToken, securityConfig, tokensEqual, validateServerExposure } from "./lib/security.js";
23
37
  import { writeServerState } from "./lib/server-state.js";
24
38
  import { generateToken } from "./lib/token.js";
39
+ import { createMcpJsonRpcHandler } from "./mcp-server.js";
25
40
  import {
26
41
  renderArtifactFormPage,
27
42
  renderArtifactPage,
43
+ renderAccountPage,
44
+ renderAdminUsersPage,
45
+ renderPasswordPage,
28
46
  renderReactFramePage,
29
47
  renderDashboard,
30
48
  renderDiffPage,
31
49
  renderImportArtifactPage,
50
+ renderLoginPage,
32
51
  renderNewArtifactPage
33
52
  } from "./lib/render.js";
34
53
 
@@ -46,6 +65,7 @@ export async function startServer(options = {}) {
46
65
  (!explicitPort && options.portFallback !== false);
47
66
  const store = createStore({ home: options.home });
48
67
  const security = securityConfig(options);
68
+ const mcpHttp = Boolean(options.mcpHttp || process.env.ARTIFACTY_MCP_HTTP === "true");
49
69
  validateServerExposure({ host, config: security });
50
70
 
51
71
  const { server, actualPort } = await listenWithFallback({
@@ -54,7 +74,7 @@ export async function startServer(options = {}) {
54
74
  allowPortFallback,
55
75
  createServer(port) {
56
76
  const candidateServer = http.createServer((request, response) => {
57
- handleRequest({ request, response, store, host, port: candidateServer.address()?.port || port, security }).catch((error) => {
77
+ handleRequest({ request, response, store, host, port: candidateServer.address()?.port || port, security, mcpHttp }).catch((error) => {
58
78
  sendError(response, error);
59
79
  });
60
80
  });
@@ -138,7 +158,7 @@ function closeServer(server) {
138
158
  });
139
159
  }
140
160
 
141
- export async function handleRequest({ request, response, store, host, port, security = securityConfig() }) {
161
+ export async function handleRequest({ request, response, store, host, port, security = securityConfig(), mcpHttp = false }) {
142
162
  const url = new URL(request.url, `http://${request.headers.host || `${host}:${port}`}`);
143
163
  const pathname = decodeURIComponent(url.pathname);
144
164
  const baseUrl = `http://${host}:${port}`;
@@ -165,8 +185,248 @@ export async function handleRequest({ request, response, store, host, port, secu
165
185
  return sendJavaScriptFile(response, vendorPath, headOnly, request);
166
186
  }
167
187
 
188
+ const userCount = await countUsers(store);
189
+ const currentUser = userCount > 0 ? await sessionUserFromRequest(store, request) : null;
190
+ if (currentUser?.passwordResetRequired && !["/account/password", "/logout"].includes(pathname)) {
191
+ return sendRedirect(response, "/account/password?required=1");
192
+ }
193
+
194
+ if (method === "GET" && pathname === "/login") {
195
+ return sendHtml(response, renderLoginPage({
196
+ baseUrl,
197
+ setup: userCount === 0,
198
+ locale,
199
+ currentPath
200
+ }), 200, headOnly);
201
+ }
202
+
203
+ if (method === "POST" && pathname === "/login") {
204
+ const body = await readFormBody(request);
205
+ const email = body.email;
206
+ const password = body.password;
207
+ const user = userCount === 0
208
+ ? await createUser(store, {
209
+ email,
210
+ name: body.name || email,
211
+ role: "admin",
212
+ password
213
+ })
214
+ : await verifyUserPassword(store, email, password);
215
+ if (!user) {
216
+ return sendHtml(response, renderLoginPage({
217
+ baseUrl,
218
+ setup: false,
219
+ error: "Invalid email or password.",
220
+ locale,
221
+ currentPath
222
+ }), 401);
223
+ }
224
+ const session = await createSession(store, user.id);
225
+ return sendRedirect(response, user.passwordResetRequired ? "/account/password?required=1" : "/account", {
226
+ "set-cookie": sessionCookie(session.token)
227
+ });
228
+ }
229
+
230
+ if (method === "POST" && pathname === "/logout") {
231
+ await revokeSession(store, sessionTokenFromRequest(request));
232
+ return sendRedirect(response, "/login", {
233
+ "set-cookie": clearSessionCookie()
234
+ });
235
+ }
236
+
237
+ if (method === "GET" && pathname === "/account/password") {
238
+ if (!currentUser) {
239
+ return sendRedirect(response, "/login");
240
+ }
241
+ return sendHtml(response, renderPasswordPage({
242
+ baseUrl,
243
+ user: currentUser,
244
+ required: currentUser.passwordResetRequired || url.searchParams.get("required") === "1",
245
+ locale,
246
+ currentPath
247
+ }), 200, headOnly);
248
+ }
249
+
250
+ if (method === "POST" && pathname === "/account/password") {
251
+ if (!currentUser) {
252
+ return sendRedirect(response, "/login");
253
+ }
254
+ const body = await readFormBody(request);
255
+ if (body.newPassword !== body.confirmPassword) {
256
+ return sendHtml(response, renderPasswordPage({
257
+ baseUrl,
258
+ user: currentUser,
259
+ required: currentUser.passwordResetRequired,
260
+ error: "New password and confirmation do not match.",
261
+ locale,
262
+ currentPath
263
+ }), 400);
264
+ }
265
+ try {
266
+ const updatedUser = await changeUserPassword(store, currentUser.id, {
267
+ currentPassword: body.currentPassword,
268
+ newPassword: body.newPassword
269
+ });
270
+ return sendHtml(response, renderPasswordPage({
271
+ baseUrl,
272
+ user: updatedUser,
273
+ success: "Password changed.",
274
+ locale,
275
+ currentPath
276
+ }));
277
+ } catch (error) {
278
+ if ((error.statusCode || 500) >= 500) {
279
+ throw error;
280
+ }
281
+ return sendHtml(response, renderPasswordPage({
282
+ baseUrl,
283
+ user: currentUser,
284
+ required: currentUser.passwordResetRequired,
285
+ error: error.message,
286
+ locale,
287
+ currentPath
288
+ }), error.statusCode || 400);
289
+ }
290
+ }
291
+
292
+ if (method === "GET" && pathname === "/account") {
293
+ if (!currentUser) {
294
+ return sendRedirect(response, "/login");
295
+ }
296
+ const tokens = await listApiTokens(store, currentUser.id);
297
+ return sendHtml(response, renderAccountPage({
298
+ baseUrl,
299
+ user: currentUser,
300
+ tokens,
301
+ locale,
302
+ currentPath
303
+ }), 200, headOnly);
304
+ }
305
+
306
+ if (method === "POST" && pathname === "/account/tokens") {
307
+ if (!currentUser) {
308
+ return sendRedirect(response, "/login");
309
+ }
310
+ const body = await readFormBody(request);
311
+ const created = await createApiToken(store, currentUser.id, {
312
+ name: body.name
313
+ });
314
+ const tokens = await listApiTokens(store, currentUser.id);
315
+ return sendHtml(response, renderAccountPage({
316
+ baseUrl,
317
+ user: currentUser,
318
+ tokens,
319
+ createdToken: created.token,
320
+ locale,
321
+ currentPath: "/account"
322
+ }));
323
+ }
324
+
325
+ const tokenRevokeMatch = /^\/account\/tokens\/([^/]+)\/revoke$/.exec(pathname);
326
+ if (tokenRevokeMatch && method === "POST") {
327
+ if (!currentUser) {
328
+ return sendRedirect(response, "/login");
329
+ }
330
+ await revokeApiToken(store, tokenRevokeMatch[1], currentUser.id);
331
+ return sendRedirect(response, "/account");
332
+ }
333
+
334
+ if (method === "GET" && pathname === "/admin/users") {
335
+ if (!currentUser) {
336
+ return sendRedirect(response, "/login");
337
+ }
338
+ requireAdmin(currentUser);
339
+ return sendHtml(response, renderAdminUsersPage({
340
+ baseUrl,
341
+ user: currentUser,
342
+ users: await listUsers(store),
343
+ locale,
344
+ currentPath
345
+ }), 200, headOnly);
346
+ }
347
+
348
+ if (method === "POST" && pathname === "/admin/users") {
349
+ if (!currentUser) {
350
+ return sendRedirect(response, "/login");
351
+ }
352
+ requireAdmin(currentUser);
353
+ const body = await readFormBody(request);
354
+ await createUser(store, {
355
+ email: body.email,
356
+ name: body.name || body.email,
357
+ role: body.role || "user",
358
+ password: body.password,
359
+ passwordResetRequired: body.passwordResetRequired === "on"
360
+ });
361
+ return sendRedirect(response, "/admin/users");
362
+ }
363
+
364
+ if (method === "POST" && pathname === "/admin/users/import") {
365
+ if (!currentUser) {
366
+ return sendRedirect(response, "/login");
367
+ }
368
+ requireAdmin(currentUser);
369
+ const body = await readFormBody(request);
370
+ try {
371
+ const importResult = await importUsersFromCsv(store, body.csv || "", {
372
+ passwordResetRequired: true
373
+ });
374
+ return sendHtml(response, renderAdminUsersPage({
375
+ baseUrl,
376
+ user: currentUser,
377
+ users: await listUsers(store),
378
+ importResult,
379
+ locale,
380
+ currentPath: "/admin/users"
381
+ }));
382
+ } catch (error) {
383
+ if ((error.statusCode || 500) >= 500) {
384
+ throw error;
385
+ }
386
+ return sendHtml(response, renderAdminUsersPage({
387
+ baseUrl,
388
+ user: currentUser,
389
+ users: await listUsers(store),
390
+ importError: error.message,
391
+ locale,
392
+ currentPath: "/admin/users"
393
+ }), error.statusCode || 400);
394
+ }
395
+ }
396
+
397
+ const userActiveMatch = /^\/admin\/users\/([^/]+)\/(enable|disable)$/.exec(pathname);
398
+ if (userActiveMatch && method === "POST") {
399
+ if (!currentUser) {
400
+ return sendRedirect(response, "/login");
401
+ }
402
+ requireAdmin(currentUser);
403
+ if (userActiveMatch[1] === currentUser.id && userActiveMatch[2] === "disable") {
404
+ throw Object.assign(new Error("Admins cannot disable their own account"), {
405
+ statusCode: 400,
406
+ code: "SELF_DISABLE_BLOCKED"
407
+ });
408
+ }
409
+ await setUserActive(store, userActiveMatch[1], userActiveMatch[2] === "enable");
410
+ return sendRedirect(response, "/admin/users");
411
+ }
412
+
413
+ if (pathname === "/mcp") {
414
+ if (!mcpHttp) {
415
+ return sendJson(response, { error: "Not found" }, 404, headOnly);
416
+ }
417
+ request.artifactyAuth = await requireRequestAuth({ store, request, url, config: security });
418
+ return handleMcpHttpRequest({
419
+ request,
420
+ response,
421
+ store,
422
+ baseUrl,
423
+ headOnly,
424
+ method
425
+ });
426
+ }
427
+
168
428
  if (pathname.startsWith("/api/")) {
169
- requireToken({ request, url, config: security });
429
+ request.artifactyAuth = await requireRequestAuth({ store, request, url, config: security });
170
430
  }
171
431
 
172
432
  if (method === "GET" && pathname === "/health") {
@@ -190,7 +450,7 @@ export async function handleRequest({ request, response, store, host, port, secu
190
450
  limit: filters.limit,
191
451
  offset: filters.offset
192
452
  });
193
- return sendHtml(response, renderDashboard({ artifacts: page.artifacts, baseUrl, filters, pagination: page, locale, currentPath }), 200, headOnly);
453
+ return sendHtml(response, renderDashboard({ artifacts: page.artifacts, baseUrl, filters, pagination: page, locale, currentPath, user: currentUser }), 200, headOnly);
194
454
  }
195
455
 
196
456
  if (method === "GET" && pathname === "/new") {
@@ -201,7 +461,7 @@ export async function handleRequest({ request, response, store, host, port, secu
201
461
  assertLocalOrigin(request);
202
462
  const body = await readFormBody(request);
203
463
  const bodyLocale = localeFromBodyOrUrl(body, url);
204
- requireToken({ request, url, body, config: security });
464
+ request.artifactyAuth = await requireBrowserWriteAuth({ store, request, url, body, config: security, currentUser });
205
465
  const artifact = await createArtifact(store, {
206
466
  title: body.title,
207
467
  content: body.content,
@@ -225,7 +485,7 @@ export async function handleRequest({ request, response, store, host, port, secu
225
485
  assertLocalOrigin(request);
226
486
  const body = await readFormBody(request);
227
487
  const bodyLocale = localeFromBodyOrUrl(body, url);
228
- requireToken({ request, url, body, config: security });
488
+ request.artifactyAuth = await requireBrowserWriteAuth({ store, request, url, body, config: security, currentUser });
229
489
  const converted = convertAgentArtifact({
230
490
  agent: body.agent,
231
491
  title: body.title,
@@ -314,7 +574,7 @@ export async function handleRequest({ request, response, store, host, port, secu
314
574
  assertLocalOrigin(request);
315
575
  const body = await readFormBody(request);
316
576
  const bodyLocale = localeFromBodyOrUrl(body, url);
317
- requireToken({ request, url, body, config: security });
577
+ request.artifactyAuth = await requireBrowserWriteAuth({ store, request, url, body, config: security, currentUser });
318
578
  const artifact = await updateArtifact(store, editMatch[1], {
319
579
  title: body.title,
320
580
  content: body.content,
@@ -357,7 +617,7 @@ export async function handleRequest({ request, response, store, host, port, secu
357
617
  assertLocalOrigin(request);
358
618
  const body = await readFormBody(request);
359
619
  const bodyLocale = localeFromBodyOrUrl(body, url);
360
- requireToken({ request, url, body, config: security });
620
+ request.artifactyAuth = await requireBrowserWriteAuth({ store, request, url, body, config: security, currentUser });
361
621
  const artifact = archiveMatch[2] === "archive"
362
622
  ? await archiveArtifact(store, archiveMatch[1], { audit: auditContext(request, "web") })
363
623
  : await restoreArtifact(store, archiveMatch[1], { audit: auditContext(request, "web") });
@@ -452,6 +712,56 @@ export function decorateArtifactUrls(artifact, baseUrl) {
452
712
  };
453
713
  }
454
714
 
715
+ async function handleMcpHttpRequest({ request, response, store, baseUrl, headOnly, method }) {
716
+ if (method === "OPTIONS") {
717
+ response.writeHead(204, {
718
+ allow: "POST, OPTIONS",
719
+ "cache-control": "no-store"
720
+ });
721
+ response.end();
722
+ return;
723
+ }
724
+
725
+ if (method !== "POST") {
726
+ response.writeHead(405, {
727
+ allow: "POST, OPTIONS",
728
+ "content-type": "application/json; charset=utf-8",
729
+ "cache-control": "no-store",
730
+ "x-content-type-options": "nosniff"
731
+ });
732
+ response.end(headOnly ? undefined : `${JSON.stringify({ error: "MCP endpoint accepts POST JSON-RPC requests" }, null, 2)}\n`);
733
+ return;
734
+ }
735
+
736
+ const body = await readJsonBody(request);
737
+ const messages = Array.isArray(body) ? body : [body];
738
+ const handler = createMcpJsonRpcHandler({
739
+ store,
740
+ transport: "streamable-http",
741
+ publicBaseUrl: baseUrl,
742
+ serverCommand: `${baseUrl}/mcp`,
743
+ browserCommand: baseUrl,
744
+ auditContext: () => auditContext(request, "mcp-http")
745
+ });
746
+ const responses = [];
747
+ for (const message of messages) {
748
+ const jsonRpcResponse = await handler(message);
749
+ if (jsonRpcResponse) {
750
+ responses.push(jsonRpcResponse);
751
+ }
752
+ }
753
+
754
+ if (responses.length === 0) {
755
+ response.writeHead(202, {
756
+ "cache-control": "no-store"
757
+ });
758
+ response.end();
759
+ return;
760
+ }
761
+
762
+ sendJson(response, Array.isArray(body) ? responses : responses[0], 200, headOnly);
763
+ }
764
+
455
765
  function paginationJson(page) {
456
766
  return {
457
767
  total: page.total,
@@ -622,10 +932,11 @@ function javascriptCorsHeaders(request) {
622
932
  };
623
933
  }
624
934
 
625
- export function sendRedirect(response, location) {
935
+ export function sendRedirect(response, location, headers = {}) {
626
936
  response.writeHead(303, {
627
937
  location,
628
- "cache-control": "no-store"
938
+ "cache-control": "no-store",
939
+ ...headers
629
940
  });
630
941
  response.end();
631
942
  }
@@ -653,12 +964,98 @@ function splitTags(value) {
653
964
  }
654
965
 
655
966
  function auditContext(request, surface) {
967
+ const auth = request.artifactyAuth;
656
968
  return {
657
969
  surface,
658
- actor: request.headers["x-artifacty-actor"] || request.headers["user-agent"] || "unknown"
970
+ actor: auth?.actor || request.headers["x-artifacty-actor"] || request.headers["user-agent"] || "unknown",
971
+ userId: auth?.user?.id || null,
972
+ tokenId: auth?.tokenId || null
659
973
  };
660
974
  }
661
975
 
976
+ async function requireRequestAuth({ store, request, url, body = {}, config }) {
977
+ const token = requestToken({ request, url, body });
978
+ if (config.apiToken && tokensEqual(token, config.apiToken)) {
979
+ return {
980
+ type: "global-token",
981
+ actor: request.headers["x-artifacty-actor"] || "artifacty-token",
982
+ role: "admin"
983
+ };
984
+ }
985
+
986
+ const tokenAuth = await authenticateApiToken(store, token);
987
+ if (tokenAuth) {
988
+ return tokenAuth;
989
+ }
990
+
991
+ const authRequired = Boolean(config.apiToken) || await countUsers(store) > 0;
992
+ if (!authRequired) {
993
+ return {
994
+ type: "anonymous",
995
+ actor: request.headers["x-artifacty-actor"] || request.headers["user-agent"] || "anonymous"
996
+ };
997
+ }
998
+
999
+ throw Object.assign(new Error("Artifacty authentication required"), {
1000
+ code: "AUTH_REQUIRED",
1001
+ statusCode: 401
1002
+ });
1003
+ }
1004
+
1005
+ async function requireBrowserWriteAuth({ store, request, url, body, config, currentUser }) {
1006
+ if (currentUser) {
1007
+ return {
1008
+ type: "session",
1009
+ actor: currentUser.email,
1010
+ user: currentUser,
1011
+ role: currentUser.role
1012
+ };
1013
+ }
1014
+ return requireRequestAuth({ store, request, url, body, config });
1015
+ }
1016
+
1017
+ function requireAdmin(user) {
1018
+ if (user?.role !== "admin") {
1019
+ throw Object.assign(new Error("Artifacty admin privileges required"), {
1020
+ code: "ADMIN_REQUIRED",
1021
+ statusCode: 403
1022
+ });
1023
+ }
1024
+ }
1025
+
1026
+ async function sessionUserFromRequest(store, request) {
1027
+ return getSessionUser(store, sessionTokenFromRequest(request));
1028
+ }
1029
+
1030
+ function sessionTokenFromRequest(request) {
1031
+ const cookies = parseCookies(request.headers.cookie || "");
1032
+ return cookies.artifacty_session || "";
1033
+ }
1034
+
1035
+ function parseCookies(header) {
1036
+ const cookies = {};
1037
+ for (const part of String(header || "").split(";")) {
1038
+ const index = part.indexOf("=");
1039
+ if (index === -1) {
1040
+ continue;
1041
+ }
1042
+ const key = part.slice(0, index).trim();
1043
+ const value = part.slice(index + 1).trim();
1044
+ if (key) {
1045
+ cookies[key] = decodeURIComponent(value);
1046
+ }
1047
+ }
1048
+ return cookies;
1049
+ }
1050
+
1051
+ function sessionCookie(token) {
1052
+ return `artifacty_session=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${7 * 24 * 60 * 60}`;
1053
+ }
1054
+
1055
+ function clearSessionCookie() {
1056
+ return "artifacty_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0";
1057
+ }
1058
+
662
1059
  function isMain(metaUrl) {
663
1060
  return process.argv[1] && metaUrl === new URL(`file://${process.argv[1]}`).href;
664
1061
  }
@@ -712,6 +1109,8 @@ function parseServerArgs(args) {
712
1109
  options.generateToken = true;
713
1110
  } else if (arg === "--allow-secrets") {
714
1111
  options.allowSecrets = true;
1112
+ } else if (arg === "--mcp-http") {
1113
+ options.mcpHttp = true;
715
1114
  }
716
1115
  }
717
1116
  return options;