artifacty 0.14.0 → 0.16.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
@@ -70,6 +70,8 @@ import {
70
70
  setArtifactVisibility,
71
71
  setReviewStatus,
72
72
  setUserActive,
73
+ setUserRole,
74
+ resetUserPassword,
73
75
  updateArtifact,
74
76
  VersionConflictError,
75
77
  verifyUserPassword
@@ -77,13 +79,17 @@ import {
77
79
  import { assertNetworkAccess, getNetworkPolicy, stageNetworkPolicy, getPendingNetworkPolicy, confirmNetworkPolicy, rollbackNetworkPolicy } from "./lib/network-policy.js";
78
80
  import { getShareLinkPolicy, setShareLinkPolicy, createShareLink, listShareLinks, revokeShareLink, resolveShareLink } from "./lib/share-links.js";
79
81
  import { installSamples, removeSamples } from "./lib/samples.js";
82
+ import { createWebUpdater } from "./lib/web-update.js";
83
+ import { getLanguageSettings, saveLanguageSettings, importLanguagePack } from "./lib/language-settings.js";
84
+ import { getSmtpSettings, getSmtpDeliveryConfig, saveSmtpSettings } from "./lib/smtp-settings.js";
85
+ import { sendSmtpMail } from "./lib/smtp-client.js";
80
86
  import { MAX_BACKUP_BYTES, buildStoreBackup, exportStoreToString, importStoreBundle, importStoreFromString } from "./lib/backup.js";
81
87
  import { convertAgentArtifact, decodeMediaContent, mediaContentType } from "./lib/converters.js";
82
88
  import { createLineDiff, createStructuredDiff, diffFormatFor, resolveDiffView } from "./lib/diff.js";
83
89
  import { EDITOR_CLIENT_PATH, VIEWER_CLIENT_PATH, editorClientFilePath, editorVendorPath, viewerClientFilePath } from "./lib/editor-assets.js";
84
90
  import { createEmbeddingProvider } from "./lib/embeddings.js";
85
91
  import { EVENT_TYPES, publish, sanitizeEventForDelivery, subscribe } from "./lib/events.js";
86
- import { localeFromBodyOrUrl, localeFromUrl, localizedHref } from "./lib/i18n.js";
92
+ import { createI18n, localeFromBodyOrUrl, localeFromUrl, localizedHref, enabledLocales, defaultLocale, runWithLanguageSettings, TRANSLATIONS } from "./lib/i18n.js";
87
93
  import { paginationJson } from "./lib/listing.js";
88
94
  import { buildOpenApiDocument, renderApiDocsHtml } from "./lib/openapi.js";
89
95
  import { getRetentionPolicy, runRetention, setRetentionPolicy, startRetentionSweep } from "./lib/retention.js";
@@ -111,6 +117,10 @@ import {
111
117
  renderAccountPage,
112
118
  renderAdminBackupPage,
113
119
  renderAdminAccessPage,
120
+ renderAccessModePreviewPage,
121
+ renderAdminUpdatePage,
122
+ renderAdminLanguagesPage,
123
+ renderAdminEmailPage,
114
124
  renderArtifactSharingPage,
115
125
  renderSharedArtifactPage,
116
126
  renderInboxPage,
@@ -189,6 +199,7 @@ export async function startServer(options = {}) {
189
199
  options.portFallback === true ||
190
200
  (!explicitPort && options.portFallback !== false);
191
201
  const store = createStore({ home: options.home });
202
+ const webUpdater = options.webUpdater || createWebUpdater({ home: store.home, packageRoot: PACKAGE_ROOT });
192
203
  const security = securityConfig(options);
193
204
  const mcpHttp = Boolean(options.mcpHttp || process.env.ARTIFACTY_MCP_HTTP === "true");
194
205
  validateServerExposure({ host, config: security });
@@ -202,7 +213,7 @@ export async function startServer(options = {}) {
202
213
  allowPortFallback,
203
214
  createServer(port) {
204
215
  const candidateServer = http.createServer((request, response) => {
205
- handleRequest({ request, response, store, host, port: candidateServer.address()?.port || port, security, mcpHttp }).catch((error) => {
216
+ handleRequest({ request, response, store, host, port: candidateServer.address()?.port || port, security, mcpHttp, webUpdater, smtpSender: options.smtpSender }).catch((error) => {
206
217
  sendError(response, error);
207
218
  });
208
219
  });
@@ -307,17 +318,67 @@ function closeServer(server) {
307
318
  });
308
319
  }
309
320
 
310
- export async function handleRequest({ request, response, store, host, port, security = securityConfig(), mcpHttp = false }) {
321
+ function isBrowserPath(pathname) {
322
+ return !/^\/(?:api(?:\/|$)|mcp(?:\/|$)|assets\/|vendor\/|health$|events$|openapi\.json$)/.test(pathname)
323
+ && !/\/(?:raw|react-frame|export)$/.test(pathname);
324
+ }
325
+
326
+ export async function handleRequest(args) {
327
+ const { request, store } = args;
311
328
  await assertNetworkAccess(store, request.socket.remoteAddress);
329
+ const pathname = decodeURIComponent(new URL(request.url, "http://artifacty.local").pathname);
330
+ if (!isBrowserPath(pathname)) return handleScopedRequest(args);
331
+ const settings = await getLanguageSettings(store);
332
+ return runWithLanguageSettings(settings, () => handleScopedRequest(args));
333
+ }
334
+
335
+ async function handleScopedRequest({ request, response, store, host, port, security = securityConfig(), mcpHttp = false, webUpdater, smtpSender = sendSmtpMail }) {
312
336
  const url = new URL(request.url, `http://${request.headers.host || `${host}:${port}`}`);
313
337
  const pathname = decodeURIComponent(url.pathname);
314
338
  const baseUrl = `http://${host}:${port}`;
315
339
  const authToken = url.searchParams.get("token") || "";
316
- const locale = localeFromUrl(url);
317
340
  const currentPath = `${url.pathname}${url.search}`;
341
+ const browserPage = isBrowserPath(pathname);
342
+ let locale = localeFromUrl(url);
343
+ if (browserPage) {
344
+ const explicit = url.searchParams.get("lang");
345
+ const cookie = parseCookies(request.headers.cookie).artifacty_locale;
346
+ const enabled = enabledLocales();
347
+ locale = enabled.includes(explicit) ? explicit
348
+ : enabled.includes(cookie) ? cookie : defaultLocale();
349
+ // Only this request-local URL changes. Keep the original path for links,
350
+ // and leave API and artifact bytes independent of browser preferences.
351
+ url.searchParams.set("lang", locale);
352
+ response.setHeader("cache-control", "no-store");
353
+ response.setHeader("vary", "Cookie");
354
+ if (enabled.includes(explicit)) {
355
+ response.setHeader("set-cookie", `artifacty_locale=${explicit}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000${request.socket.encrypted ? "; Secure" : ""}`);
356
+ }
357
+ }
318
358
  const headOnly = request.method === "HEAD";
319
359
  const method = headOnly ? "GET" : request.method;
320
360
 
361
+ if (method === "GET" && pathname === "/assets/update.js") {
362
+ return sendJavaScriptFile(response, path.join(PACKAGE_ROOT, "src", "client", "update.js"), headOnly, request);
363
+ }
364
+ if (method === "GET" && pathname === "/assets/theme.js") {
365
+ return sendJavaScriptFile(response, path.join(PACKAGE_ROOT, "src", "client", "theme.js"), headOnly, request);
366
+ }
367
+ if (method === "GET" && pathname === "/assets/auto-apply.js") {
368
+ return sendJavaScriptFile(response, path.join(PACKAGE_ROOT, "src", "client", "auto-apply.js"), headOnly, request);
369
+ }
370
+ if (method === "GET" && pathname === "/assets/sidebar.js") {
371
+ return sendJavaScriptFile(response, path.join(PACKAGE_ROOT, "src", "client", "sidebar.js"), headOnly, request);
372
+ }
373
+ if (method === "GET" && pathname === "/assets/ui-controls.css") {
374
+ const content = await readFile(path.join(PACKAGE_ROOT, "src", "client", "ui-controls.css"), "utf8");
375
+ response.writeHead(200, { "content-type": "text/css; charset=utf-8", "cache-control": "no-store", "x-content-type-options": "nosniff" });
376
+ return response.end(headOnly ? undefined : content);
377
+ }
378
+ if (method === "GET" && pathname === "/assets/api-docs.js") {
379
+ return sendJavaScriptFile(response, path.join(PACKAGE_ROOT, "src", "client", "api-docs.js"), headOnly, request);
380
+ }
381
+
321
382
  const publicShareMatch = /^\/s\/([^/]+)(\/raw)?$/.exec(pathname);
322
383
  if (publicShareMatch && method === "GET") {
323
384
  const shared = await resolveShareLink(store, publicShareMatch[1]);
@@ -375,7 +436,7 @@ export async function handleRequest({ request, response, store, host, port, secu
375
436
  }
376
437
 
377
438
  if (method === "GET" && pathname === "/docs/api") {
378
- return sendHtml(response, renderApiDocsHtml({ baseUrl }), 200, headOnly);
439
+ return sendHtml(response, renderApiDocsHtml({ baseUrl, locale, currentPath: `${url.pathname}${url.search}${url.hash}` }), 200, headOnly);
379
440
  }
380
441
 
381
442
  const userCount = await countUsers(store);
@@ -396,6 +457,7 @@ export async function handleRequest({ request, response, store, host, port, secu
396
457
  if (method === "POST" && pathname === "/login") {
397
458
  await enforceRateLimit({ store, host, request, bucket: "auth" });
398
459
  const body = await readFormBody(request);
460
+ const bodyLocale = localeFromBodyOrUrl(body, url);
399
461
  const email = body.email;
400
462
  const password = body.password;
401
463
  const user = userCount === 0
@@ -410,13 +472,13 @@ export async function handleRequest({ request, response, store, host, port, secu
410
472
  return sendHtml(response, renderLoginPage({
411
473
  baseUrl,
412
474
  setup: false,
413
- error: "Invalid email or password.",
414
- locale,
475
+ error: createI18n(bodyLocale).t("auth.invalidCredentials"),
476
+ locale: bodyLocale,
415
477
  currentPath
416
478
  }), 401);
417
479
  }
418
480
  const session = await createSession(store, user.id);
419
- return sendRedirect(response, user.passwordResetRequired ? "/account/password?required=1" : "/account", {
481
+ return sendRedirect(response, localizedHref(user.passwordResetRequired ? "/account/password?required=1" : "/account", bodyLocale), {
420
482
  "set-cookie": sessionCookie(session.token)
421
483
  });
422
484
  }
@@ -446,13 +508,14 @@ export async function handleRequest({ request, response, store, host, port, secu
446
508
  return sendRedirect(response, "/login");
447
509
  }
448
510
  const body = await readFormBody(request);
511
+ const bodyLocale = localeFromBodyOrUrl(body, url);
449
512
  if (body.newPassword !== body.confirmPassword) {
450
513
  return sendHtml(response, renderPasswordPage({
451
514
  baseUrl,
452
515
  user: currentUser,
453
516
  required: currentUser.passwordResetRequired,
454
- error: "New password and confirmation do not match.",
455
- locale,
517
+ error: createI18n(bodyLocale).t("password.mismatch"),
518
+ locale: bodyLocale,
456
519
  currentPath
457
520
  }), 400);
458
521
  }
@@ -464,8 +527,8 @@ export async function handleRequest({ request, response, store, host, port, secu
464
527
  return sendHtml(response, renderPasswordPage({
465
528
  baseUrl,
466
529
  user: updatedUser,
467
- success: "Password changed.",
468
- locale,
530
+ success: createI18n(bodyLocale).t("password.changed"),
531
+ locale: bodyLocale,
469
532
  currentPath
470
533
  }));
471
534
  } catch (error) {
@@ -546,6 +609,93 @@ export async function handleRequest({ request, response, store, host, port, secu
546
609
  }
547
610
 
548
611
  const browserAccess = accessContext(request, currentUser);
612
+ if (pathname === "/admin/email") {
613
+ if (!currentUser) throw Object.assign(new Error("An administrator browser session is required"), { statusCode: 401 });
614
+ requireAdmin(currentUser);
615
+ const emailPage = async (extra = {}) => renderAdminEmailPage({
616
+ baseUrl, user: currentUser, locale, currentPath: "/admin/email", settings: await getSmtpSettings(store), ...extra
617
+ });
618
+ if (method === "GET") return sendHtml(response, await emailPage(), 200, headOnly);
619
+ if (method === "POST") {
620
+ assertSameOrigin(request);
621
+ await enforceRateLimit({ store, host, request, bucket: "write" });
622
+ const body = await readFormBody(request, 16 * 1024);
623
+ try {
624
+ await saveSmtpSettings(store, {
625
+ enabled: body.enabled === "on" || body.enabled === "true", host: body.host || "", port: Number(body.port),
626
+ tlsMode: body.tlsMode, username: body.username || "", from: body.from || "", password: body.password || ""
627
+ }, { access: browserAccess, audit: { surface: "web", actor: currentUser.email, userId: currentUser.id } });
628
+ return sendRedirect(response, localizedHref("/admin/email", localeFromBodyOrUrl(body, url)));
629
+ } catch (error) {
630
+ if (error.statusCode !== 400) throw error;
631
+ return sendHtml(response, await emailPage({ error: createI18n(locale).t("smtp.invalidSettings") }), 400);
632
+ }
633
+ }
634
+ return sendJson(response, { error: "Method not allowed" }, 405, headOnly);
635
+ }
636
+ if (["/admin/languages", "/admin/languages/import", "/admin/languages/template"].includes(pathname)) {
637
+ if (!currentUser) throw Object.assign(new Error("An administrator browser session is required"), { statusCode: 401 });
638
+ requireAdmin(currentUser);
639
+ if (method === "GET" && pathname === "/admin/languages/template") {
640
+ response.setHeader("content-disposition", 'attachment; filename="artifacty-language-template.json"');
641
+ return sendJson(response, { locale: "en", nativeName: "English", messages: TRANSLATIONS.en }, 200, headOnly);
642
+ }
643
+ const renderLanguages = async (extra = {}) => renderAdminLanguagesPage({
644
+ baseUrl, user: currentUser, locale, currentPath: "/admin/languages",
645
+ settings: await getLanguageSettings(store), ...extra
646
+ });
647
+ if (method === "GET" && pathname === "/admin/languages") return sendHtml(response, await renderLanguages(), 200, headOnly);
648
+ if (method === "POST" && pathname !== "/admin/languages/template") {
649
+ assertSameOrigin(request);
650
+ await enforceRateLimit({ store, host, request, bucket: "write" });
651
+ try {
652
+ // URL encoding can use three bytes for each byte of the JSON text.
653
+ const body = await readFormBody(request, 3 * 512 * 1024 + 1024);
654
+ const options = { access: browserAccess, audit: { surface: "web", actor: currentUser.email, userId: currentUser.id } };
655
+ if (pathname.endsWith("/import")) {
656
+ if (Buffer.byteLength(String(body.pack || ""), "utf8") > 512 * 1024) {
657
+ throw Object.assign(new Error(createI18n(locale).t("languageAdmin.tooLarge")), { statusCode: 413 });
658
+ }
659
+ let pack;
660
+ try { pack = JSON.parse(body.pack); }
661
+ catch { throw Object.assign(new Error(createI18n(locale).t("languageAdmin.invalidJson")), { statusCode: 400 }); }
662
+ await importLanguagePack(store, pack, options);
663
+ } else {
664
+ await saveLanguageSettings(store, { defaultLocale: body.defaultLocale, enabledLocales: body.enabledLocales || [] }, options);
665
+ }
666
+ return sendRedirect(response, "/admin/languages");
667
+ } catch (error) {
668
+ if (![400, 413].includes(error.statusCode)) throw error;
669
+ return sendHtml(response, await renderLanguages({ error: error.message }), error.statusCode);
670
+ }
671
+ }
672
+ return sendJson(response, { error: "Method not allowed" }, 405, headOnly);
673
+ }
674
+ if (["/admin/update", "/admin/update/status", "/admin/update/check", "/admin/update/start"].includes(pathname)) {
675
+ if (!currentUser) throw Object.assign(new Error("An administrator browser session is required"), { statusCode: 401 });
676
+ requireAdmin(currentUser);
677
+ response.setHeader("cache-control", "no-store");
678
+ const updater = webUpdater || createWebUpdater({ home: store.home, packageRoot: PACKAGE_ROOT });
679
+ const session = sessionTokenFromRequest(request);
680
+ if (method === "GET" && pathname === "/admin/update/status") return sendJson(response, await updater.status(session), 200, headOnly);
681
+ if (method === "GET" && pathname === "/admin/update") {
682
+ return sendHtml(response, renderAdminUpdatePage({ baseUrl, user: currentUser, locale, currentPath, status: await updater.status(session) }), 200, headOnly);
683
+ }
684
+ if (method === "POST" && ["/admin/update/check", "/admin/update/start"].includes(pathname)) {
685
+ assertLocalOrigin(request);
686
+ const expectedOrigin = `${request.socket.encrypted ? "https" : "http"}://${request.headers.host}`;
687
+ if (request.headers.origin !== expectedOrigin) throw Object.assign(new Error("An exact same-origin request is required"), { statusCode: 403, code: "UPDATE_ORIGIN_REQUIRED" });
688
+ await enforceRateLimit({ store, host, request, bucket: "write" });
689
+ const body = await readFormBody(request);
690
+ if (pathname.endsWith("/check")) {
691
+ const status = await updater.check(session);
692
+ return sendHtml(response, renderAdminUpdatePage({ baseUrl, user: currentUser, locale: localeFromBodyOrUrl(body, url), currentPath: "/admin/update", status }));
693
+ }
694
+ await updater.start({ session, confirmationId: body.confirmationId, targetVersion: body.targetVersion, confirmUpdate: body.confirmUpdate });
695
+ return sendRedirect(response, localizedHref("/admin/update", localeFromBodyOrUrl(body, url)));
696
+ }
697
+ return sendJson(response, { error: "Method not allowed" }, 405, headOnly);
698
+ }
549
699
  const browserAudit = { surface: "web", actor: currentUser?.email, userId: currentUser?.id };
550
700
  const adminAccessPage = async (extra = {}) => renderAdminAccessPage({
551
701
  baseUrl, user: currentUser, mode: await getAccessMode(store),
@@ -559,16 +709,23 @@ export async function handleRequest({ request, response, store, host, port, secu
559
709
  if (!currentUser) return sendRedirect(response, "/login");
560
710
  requireAdmin(currentUser);
561
711
  if (method === "GET" && pathname === "/admin/access") {
562
- return sendHtml(response, await adminAccessPage(), 200, headOnly);
712
+ const selectedMode = url.searchParams.get("mode");
713
+ const preview = ["public", "private"].includes(selectedMode)
714
+ ? await previewAccessMode(store, selectedMode, { access: browserAccess }) : null;
715
+ return sendHtml(response, await adminAccessPage({ preview }), 200, headOnly);
563
716
  }
564
717
  if (method === "POST") {
565
718
  assertLocalOrigin(request);
719
+ if (pathname.startsWith("/admin/access")) assertSameOrigin(request);
566
720
  request.artifactyAuth = await requireBrowserWriteAuth({ store, request, url, config: security, currentUser });
567
721
  await enforceRateLimit({ store, host, request, bucket: "write" });
568
722
  const body = await readFormBody(request);
569
723
  const options = { access: browserAccess, audit: browserAudit, remoteAddress: request.socket.remoteAddress };
570
724
  if (pathname === "/admin/access/preview") {
571
- return sendHtml(response, await adminAccessPage({ preview: await previewAccessMode(store, body.mode, options) }), 200, headOnly);
725
+ return sendHtml(response, renderAccessModePreviewPage({
726
+ baseUrl, user: currentUser, locale: localeFromBodyOrUrl(body, url), currentPath: "/admin/access/preview",
727
+ ...await previewAccessMode(store, body.mode, options)
728
+ }), 200, headOnly);
572
729
  } else if (pathname === "/admin/access") {
573
730
  await setAccessMode(store, body.mode, options);
574
731
  } else if (pathname === "/admin/access/network") {
@@ -779,6 +936,67 @@ export async function handleRequest({ request, response, store, host, port, secu
779
936
  }
780
937
  }
781
938
 
939
+ const userPasswordResetMatch = /^\/admin\/users\/([^/]+)\/reset-password$/.exec(pathname);
940
+ if (userPasswordResetMatch && method === "POST") {
941
+ if (!currentUser) throw Object.assign(new Error("An administrator browser session is required"), { statusCode: 401 });
942
+ requireAdmin(currentUser);
943
+ assertSameOrigin(request);
944
+ await enforceRateLimit({ store, host, request, bucket: "write" });
945
+ const body = await readFormBody(request);
946
+ const resetResult = await resetUserPassword(store, userPasswordResetMatch[1], {
947
+ access: browserAccess, audit: { surface: "web", actor: currentUser.email, userId: currentUser.id }
948
+ });
949
+ resetResult.mailStatus = "not-configured";
950
+ try {
951
+ const smtp = await getSmtpDeliveryConfig(store);
952
+ if (smtp.enabled) {
953
+ const messages = createI18n(localeFromBodyOrUrl(body, url));
954
+ await smtpSender(smtp, {
955
+ to: resetResult.user.email,
956
+ subject: messages.t("smtp.resetSubject"),
957
+ text: messages.t("smtp.resetBody", { email: resetResult.user.email, password: resetResult.temporaryPassword })
958
+ });
959
+ resetResult.mailStatus = "sent";
960
+ }
961
+ } catch {
962
+ // Reset already succeeded. Keep the one-time password available for
963
+ // manual delivery, including when SMTP acceptance is uncertain.
964
+ resetResult.mailStatus = "failed";
965
+ }
966
+ response.setHeader("cache-control", "no-store");
967
+ return sendHtml(response, renderAdminUsersPage({
968
+ baseUrl, user: currentUser, users: await listUsers(store), resetResult,
969
+ locale: localeFromBodyOrUrl(body, url), currentPath: "/admin/users"
970
+ }));
971
+ }
972
+
973
+ const userRoleMatch = /^\/admin\/users\/([^/]+)\/role$/.exec(pathname);
974
+ if (userRoleMatch && method === "POST") {
975
+ if (!currentUser) throw Object.assign(new Error("An administrator browser session is required"), { statusCode: 401 });
976
+ requireAdmin(currentUser);
977
+ assertLocalOrigin(request);
978
+ if (request.headers.origin !== `${request.socket.encrypted ? "https" : "http"}://${request.headers.host}`) {
979
+ throw Object.assign(new Error("An exact same-origin request is required"), { statusCode: 403, code: "ROLE_ORIGIN_REQUIRED" });
980
+ }
981
+ await enforceRateLimit({ store, host, request, bucket: "write" });
982
+ const body = await readFormBody(request);
983
+ const bodyLocale = localeFromBodyOrUrl(body, url);
984
+ try {
985
+ await setUserRole(store, userRoleMatch[1], body.role, {
986
+ access: browserAccess, audit: { surface: "web", actor: currentUser.email, userId: currentUser.id }
987
+ });
988
+ const destination = userRoleMatch[1] === currentUser.id && body.role === "user" ? "/account" : "/admin/users";
989
+ return sendRedirect(response, localizedHref(destination, bodyLocale));
990
+ } catch (error) {
991
+ if (![400, 409].includes(error.statusCode)) throw error;
992
+ return sendHtml(response, renderAdminUsersPage({
993
+ baseUrl, user: currentUser, users: await listUsers(store), locale: bodyLocale, currentPath: "/admin/users",
994
+ roleError: error.code === "LAST_ADMIN_REQUIRED" ? createI18n(bodyLocale).t("users.lastAdmin")
995
+ : error.code === "INVALID_USER_ROLE" ? createI18n(bodyLocale).t("users.invalidRole") : error.message
996
+ }), error.statusCode);
997
+ }
998
+ }
999
+
782
1000
  const userActiveMatch = /^\/admin\/users\/([^/]+)\/(enable|disable)$/.exec(pathname);
783
1001
  if (userActiveMatch && method === "POST") {
784
1002
  if (!currentUser) {
@@ -1048,7 +1266,7 @@ export async function handleRequest({ request, response, store, host, port, secu
1048
1266
  }
1049
1267
 
1050
1268
  if (method === "GET" && pathname === "/new") {
1051
- return sendHtml(response, renderNewArtifactPage({ baseUrl, authToken, locale, currentPath }), 200, headOnly);
1269
+ return sendHtml(response, renderNewArtifactPage({ baseUrl, authToken, user: currentUser, locale, currentPath }), 200, headOnly);
1052
1270
  }
1053
1271
 
1054
1272
  if (method === "POST" && pathname === "/new") {
@@ -1075,7 +1293,7 @@ export async function handleRequest({ request, response, store, host, port, secu
1075
1293
  }
1076
1294
 
1077
1295
  if (method === "GET" && pathname === "/import") {
1078
- return sendHtml(response, renderImportArtifactPage({ baseUrl, authToken, locale, currentPath }), 200, headOnly);
1296
+ return sendHtml(response, renderImportArtifactPage({ baseUrl, authToken, user: currentUser, locale, currentPath }), 200, headOnly);
1079
1297
  }
1080
1298
 
1081
1299
  if (method === "POST" && pathname === "/import") {
@@ -1342,6 +1560,7 @@ export async function handleRequest({ request, response, store, host, port, secu
1342
1560
  access: accessContext(request, currentUser)
1343
1561
  });
1344
1562
  return sendHtml(response, renderArtifactFormPage({
1563
+ user: currentUser,
1345
1564
  mode: "edit",
1346
1565
  baseUrl,
1347
1566
  artifact,
@@ -1388,6 +1607,7 @@ export async function handleRequest({ request, response, store, host, port, secu
1388
1607
  tags: splitTags(body.tags)
1389
1608
  };
1390
1609
  return sendHtml(response, renderArtifactFormPage({
1610
+ user: currentUser,
1391
1611
  mode: "edit",
1392
1612
  baseUrl,
1393
1613
  artifact: unsavedArtifact,
@@ -1419,6 +1639,7 @@ export async function handleRequest({ request, response, store, host, port, secu
1419
1639
  ? createStructuredDiff(from.content, to.content, { format: diffFormat })
1420
1640
  : null;
1421
1641
  return sendHtml(response, renderDiffPage({
1642
+ user: currentUser,
1422
1643
  artifact: latest,
1423
1644
  fromVersion: from.version,
1424
1645
  toVersion: to.version,
@@ -1897,7 +2118,7 @@ export async function readFormBody(request, limitBytes = MAX_ARTIFACT_BYTES + 10
1897
2118
  const params = new URLSearchParams(raw);
1898
2119
  const body = Object.fromEntries(params.entries());
1899
2120
  // Sharing checkboxes submit repeated keys; scalar form fields keep their existing shape.
1900
- for (const key of ["userIds", "teamIds"]) {
2121
+ for (const key of ["userIds", "teamIds", "enabledLocales"]) {
1901
2122
  if (params.has(key)) body[key] = params.getAll(key);
1902
2123
  }
1903
2124
  return body;
@@ -1919,6 +2140,13 @@ export async function readBody(request, limitBytes) {
1919
2140
  return Buffer.concat(chunks).toString("utf8");
1920
2141
  }
1921
2142
 
2143
+ function assertSameOrigin(request) {
2144
+ const expected = `${request.socket.encrypted ? "https" : "http"}://${request.headers.host}`;
2145
+ if (request.headers.origin !== expected) {
2146
+ throw Object.assign(new Error("An exact same-origin request is required"), { statusCode: 403, code: "SAME_ORIGIN_REQUIRED" });
2147
+ }
2148
+ }
2149
+
1922
2150
  export function assertLocalOrigin(request) {
1923
2151
  const origin = request.headers.origin;
1924
2152
  if (!origin) {
@@ -2165,6 +2393,10 @@ function javascriptCorsHeaders(request) {
2165
2393
  }
2166
2394
 
2167
2395
  export function sendRedirect(response, location, headers = {}) {
2396
+ const existingCookies = response.getHeader("set-cookie");
2397
+ if (existingCookies && headers["set-cookie"]) {
2398
+ headers = { ...headers, "set-cookie": [...[].concat(existingCookies), ...[].concat(headers["set-cookie"])] };
2399
+ }
2168
2400
  response.writeHead(303, {
2169
2401
  location,
2170
2402
  "cache-control": "no-store",
@@ -2741,7 +2973,7 @@ function parseCookies(header) {
2741
2973
  const key = part.slice(0, index).trim();
2742
2974
  const value = part.slice(index + 1).trim();
2743
2975
  if (key) {
2744
- cookies[key] = decodeURIComponent(value);
2976
+ try { cookies[key] = decodeURIComponent(value); } catch { /* Ignore malformed cookies. */ }
2745
2977
  }
2746
2978
  }
2747
2979
  return cookies;
@@ -0,0 +1,63 @@
1
+ // Artifacty control layer: official Bootstrap components only; no Reboot or layout reset.
2
+ @import "bootstrap/scss/functions";
3
+ $primary: #315ca8;
4
+ $secondary: #58616e;
5
+ $danger: #b42332;
6
+ $font-family-sans-serif: ui-sans-serif, system-ui, -apple-system, "Segoe UI", "Apple SD Gothic Neo", "Malgun Gothic", sans-serif;
7
+ $font-size-base: .875rem;
8
+ $border-radius: .375rem;
9
+ $input-btn-font-size: .8125rem;
10
+ $input-btn-padding-y: .4375rem;
11
+ $input-btn-padding-x: .75rem;
12
+ $btn-font-weight: 500;
13
+ $btn-white-space: nowrap;
14
+ $input-btn-focus-width: .1875rem;
15
+ $input-btn-focus-color-opacity: .2;
16
+ $enable-shadows: false;
17
+ @import "bootstrap/scss/variables";
18
+ @import "bootstrap/scss/variables-dark";
19
+ @import "bootstrap/scss/maps";
20
+ @import "bootstrap/scss/mixins";
21
+ @import "bootstrap/scss/root";
22
+ @import "bootstrap/scss/forms";
23
+ @import "bootstrap/scss/buttons";
24
+ @import "bootstrap/scss/button-group";
25
+
26
+ :root, [data-bs-theme="dark"] {
27
+ --bs-body-font-family: #{$font-family-sans-serif};
28
+ --bs-body-color: var(--text, #20242b);
29
+ --bs-body-bg: var(--panel, #fff);
30
+ --bs-secondary-color: var(--muted, #58616e);
31
+ --bs-border-color: var(--line-2, #b9c1cc);
32
+ --bs-secondary-bg: var(--panel-2, #eef0f3);
33
+ }
34
+ .btn { min-height: 36px; display: inline-flex; align-items: center; justify-content: center; gap: .375rem; }
35
+ .btn[hidden] { display: none !important; }
36
+ .form-control, .form-select { min-height: 38px; font-family: var(--bs-body-font-family); }
37
+ .form-control::placeholder { color: var(--muted, #58616e); opacity: .85; }
38
+ .form-control:focus, .form-select:focus { border-color: var(--accent, #315ca8); }
39
+ .btn-outline-secondary, .btn-ghost {
40
+ --bs-btn-color: var(--text, #20242b);
41
+ --bs-btn-border-color: var(--line, #dfe3e8);
42
+ --bs-btn-hover-color: var(--text, #20242b);
43
+ --bs-btn-hover-bg: var(--panel-2, #eef0f3);
44
+ --bs-btn-hover-border-color: var(--line-2, #b9c1cc);
45
+ --bs-btn-active-color: var(--text, #20242b);
46
+ --bs-btn-active-bg: var(--panel-2, #eef0f3);
47
+ --bs-btn-active-border-color: var(--line-2, #b9c1cc);
48
+ --bs-btn-disabled-color: var(--muted, #58616e);
49
+ --bs-btn-disabled-border-color: var(--line, #dfe3e8);
50
+ --bs-btn-focus-shadow-rgb: 49, 92, 168;
51
+ }
52
+ .btn-ghost { --bs-btn-border-color: transparent; }
53
+ [data-bs-theme="dark"] .btn-outline-danger {
54
+ --bs-btn-color: #ff9ba5;
55
+ --bs-btn-border-color: #73404a;
56
+ --bs-btn-hover-bg: #93313e;
57
+ --bs-btn-hover-border-color: #93313e;
58
+ }
59
+ .btn:focus-visible { outline: 2px solid var(--ring, #315ca8); outline-offset: 2px; }
60
+ @media (pointer: coarse) {
61
+ .btn, .form-control, .form-select { min-height: 44px; }
62
+ }
63
+ form[data-auto-apply] [hidden] { display: none !important; }
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { runWebUpdate } from "./lib/web-update.js";
3
+
4
+ runWebUpdate(process.argv[2]).catch(() => {
5
+ console.error("Artifacty update worker could not finish. Inspect its persisted status and user service.");
6
+ process.exitCode = 1;
7
+ });