artifacty 0.15.0 → 0.16.1

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
@@ -78,13 +80,16 @@ import { assertNetworkAccess, getNetworkPolicy, stageNetworkPolicy, getPendingNe
78
80
  import { getShareLinkPolicy, setShareLinkPolicy, createShareLink, listShareLinks, revokeShareLink, resolveShareLink } from "./lib/share-links.js";
79
81
  import { installSamples, removeSamples } from "./lib/samples.js";
80
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";
81
86
  import { MAX_BACKUP_BYTES, buildStoreBackup, exportStoreToString, importStoreBundle, importStoreFromString } from "./lib/backup.js";
82
87
  import { convertAgentArtifact, decodeMediaContent, mediaContentType } from "./lib/converters.js";
83
88
  import { createLineDiff, createStructuredDiff, diffFormatFor, resolveDiffView } from "./lib/diff.js";
84
89
  import { EDITOR_CLIENT_PATH, VIEWER_CLIENT_PATH, editorClientFilePath, editorVendorPath, viewerClientFilePath } from "./lib/editor-assets.js";
85
90
  import { createEmbeddingProvider } from "./lib/embeddings.js";
86
91
  import { EVENT_TYPES, publish, sanitizeEventForDelivery, subscribe } from "./lib/events.js";
87
- import { localeFromBodyOrUrl, localeFromUrl, localizedHref } from "./lib/i18n.js";
92
+ import { createI18n, localeFromBodyOrUrl, localeFromUrl, localizedHref, enabledLocales, defaultLocale, runWithLanguageSettings, TRANSLATIONS } from "./lib/i18n.js";
88
93
  import { paginationJson } from "./lib/listing.js";
89
94
  import { buildOpenApiDocument, renderApiDocsHtml } from "./lib/openapi.js";
90
95
  import { getRetentionPolicy, runRetention, setRetentionPolicy, startRetentionSweep } from "./lib/retention.js";
@@ -112,7 +117,10 @@ import {
112
117
  renderAccountPage,
113
118
  renderAdminBackupPage,
114
119
  renderAdminAccessPage,
120
+ renderAccessModePreviewPage,
115
121
  renderAdminUpdatePage,
122
+ renderAdminLanguagesPage,
123
+ renderAdminEmailPage,
116
124
  renderArtifactSharingPage,
117
125
  renderSharedArtifactPage,
118
126
  renderInboxPage,
@@ -205,7 +213,7 @@ export async function startServer(options = {}) {
205
213
  allowPortFallback,
206
214
  createServer(port) {
207
215
  const candidateServer = http.createServer((request, response) => {
208
- handleRequest({ request, response, store, host, port: candidateServer.address()?.port || port, security, mcpHttp, webUpdater }).catch((error) => {
216
+ handleRequest({ request, response, store, host, port: candidateServer.address()?.port || port, security, mcpHttp, webUpdater, smtpSender: options.smtpSender }).catch((error) => {
209
217
  sendError(response, error);
210
218
  });
211
219
  });
@@ -310,24 +318,78 @@ function closeServer(server) {
310
318
  });
311
319
  }
312
320
 
313
- export async function handleRequest({ request, response, store, host, port, security = securityConfig(), mcpHttp = false, webUpdater }) {
314
- await assertNetworkAccess(store, request.socket.remoteAddress);
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;
328
+ let networkSharedArtifact;
329
+ try { assertNetworkAccess(store, request.socket.remoteAddress); }
330
+ catch (cause) {
331
+ const match = /^\/s\/([A-Za-z0-9_-]{43})(?:\/raw)?$/.exec(new URL(request.url, "http://artifacty.local").pathname);
332
+ if (cause.code !== "IP_NOT_ALLOWED" || !["GET", "HEAD"].includes(request.method) || !match || !getNetworkPolicy(store).allowSharedLinks) throw cause;
333
+ // Authenticate the capability before admitting an otherwise blocked peer.
334
+ networkSharedArtifact = await resolveShareLink(store, match[1]);
335
+ }
336
+ args = { ...args, networkSharedArtifact };
337
+ const pathname = decodeURIComponent(new URL(request.url, "http://artifacty.local").pathname);
338
+ if (!isBrowserPath(pathname)) return handleScopedRequest(args);
339
+ const settings = await getLanguageSettings(store);
340
+ return runWithLanguageSettings(settings, () => handleScopedRequest(args));
341
+ }
342
+
343
+ async function handleScopedRequest({ request, response, store, host, port, security = securityConfig(), mcpHttp = false, webUpdater, smtpSender = sendSmtpMail, networkSharedArtifact }) {
315
344
  const url = new URL(request.url, `http://${request.headers.host || `${host}:${port}`}`);
316
345
  const pathname = decodeURIComponent(url.pathname);
317
346
  const baseUrl = `http://${host}:${port}`;
318
347
  const authToken = url.searchParams.get("token") || "";
319
- const locale = localeFromUrl(url);
320
348
  const currentPath = `${url.pathname}${url.search}`;
349
+ const browserPage = isBrowserPath(pathname);
350
+ let locale = localeFromUrl(url);
351
+ if (browserPage) {
352
+ const explicit = url.searchParams.get("lang");
353
+ const cookie = parseCookies(request.headers.cookie).artifacty_locale;
354
+ const enabled = enabledLocales();
355
+ locale = enabled.includes(explicit) ? explicit
356
+ : enabled.includes(cookie) ? cookie : defaultLocale();
357
+ // Only this request-local URL changes. Keep the original path for links,
358
+ // and leave API and artifact bytes independent of browser preferences.
359
+ url.searchParams.set("lang", locale);
360
+ response.setHeader("cache-control", "no-store");
361
+ response.setHeader("vary", "Cookie");
362
+ if (enabled.includes(explicit)) {
363
+ response.setHeader("set-cookie", `artifacty_locale=${explicit}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000${request.socket.encrypted ? "; Secure" : ""}`);
364
+ }
365
+ }
321
366
  const headOnly = request.method === "HEAD";
322
367
  const method = headOnly ? "GET" : request.method;
323
368
 
324
369
  if (method === "GET" && pathname === "/assets/update.js") {
325
370
  return sendJavaScriptFile(response, path.join(PACKAGE_ROOT, "src", "client", "update.js"), headOnly, request);
326
371
  }
372
+ if (method === "GET" && pathname === "/assets/theme.js") {
373
+ return sendJavaScriptFile(response, path.join(PACKAGE_ROOT, "src", "client", "theme.js"), headOnly, request);
374
+ }
375
+ if (method === "GET" && pathname === "/assets/auto-apply.js") {
376
+ return sendJavaScriptFile(response, path.join(PACKAGE_ROOT, "src", "client", "auto-apply.js"), headOnly, request);
377
+ }
378
+ if (method === "GET" && pathname === "/assets/sidebar.js") {
379
+ return sendJavaScriptFile(response, path.join(PACKAGE_ROOT, "src", "client", "sidebar.js"), headOnly, request);
380
+ }
381
+ if (method === "GET" && pathname === "/assets/ui-controls.css") {
382
+ const content = await readFile(path.join(PACKAGE_ROOT, "src", "client", "ui-controls.css"), "utf8");
383
+ response.writeHead(200, { "content-type": "text/css; charset=utf-8", "cache-control": "no-store", "x-content-type-options": "nosniff" });
384
+ return response.end(headOnly ? undefined : content);
385
+ }
386
+ if (method === "GET" && pathname === "/assets/api-docs.js") {
387
+ return sendJavaScriptFile(response, path.join(PACKAGE_ROOT, "src", "client", "api-docs.js"), headOnly, request);
388
+ }
327
389
 
328
390
  const publicShareMatch = /^\/s\/([^/]+)(\/raw)?$/.exec(pathname);
329
391
  if (publicShareMatch && method === "GET") {
330
- const shared = await resolveShareLink(store, publicShareMatch[1]);
392
+ const shared = networkSharedArtifact || await resolveShareLink(store, publicShareMatch[1]);
331
393
  response.setHeader("referrer-policy", "no-referrer");
332
394
  response.setHeader("cache-control", "no-store");
333
395
  if (publicShareMatch[2]) {
@@ -382,7 +444,7 @@ export async function handleRequest({ request, response, store, host, port, secu
382
444
  }
383
445
 
384
446
  if (method === "GET" && pathname === "/docs/api") {
385
- return sendHtml(response, renderApiDocsHtml({ baseUrl }), 200, headOnly);
447
+ return sendHtml(response, renderApiDocsHtml({ baseUrl, locale, currentPath: `${url.pathname}${url.search}${url.hash}` }), 200, headOnly);
386
448
  }
387
449
 
388
450
  const userCount = await countUsers(store);
@@ -403,6 +465,7 @@ export async function handleRequest({ request, response, store, host, port, secu
403
465
  if (method === "POST" && pathname === "/login") {
404
466
  await enforceRateLimit({ store, host, request, bucket: "auth" });
405
467
  const body = await readFormBody(request);
468
+ const bodyLocale = localeFromBodyOrUrl(body, url);
406
469
  const email = body.email;
407
470
  const password = body.password;
408
471
  const user = userCount === 0
@@ -417,13 +480,13 @@ export async function handleRequest({ request, response, store, host, port, secu
417
480
  return sendHtml(response, renderLoginPage({
418
481
  baseUrl,
419
482
  setup: false,
420
- error: "Invalid email or password.",
421
- locale,
483
+ error: createI18n(bodyLocale).t("auth.invalidCredentials"),
484
+ locale: bodyLocale,
422
485
  currentPath
423
486
  }), 401);
424
487
  }
425
488
  const session = await createSession(store, user.id);
426
- return sendRedirect(response, user.passwordResetRequired ? "/account/password?required=1" : "/account", {
489
+ return sendRedirect(response, localizedHref(user.passwordResetRequired ? "/account/password?required=1" : "/account", bodyLocale), {
427
490
  "set-cookie": sessionCookie(session.token)
428
491
  });
429
492
  }
@@ -453,13 +516,14 @@ export async function handleRequest({ request, response, store, host, port, secu
453
516
  return sendRedirect(response, "/login");
454
517
  }
455
518
  const body = await readFormBody(request);
519
+ const bodyLocale = localeFromBodyOrUrl(body, url);
456
520
  if (body.newPassword !== body.confirmPassword) {
457
521
  return sendHtml(response, renderPasswordPage({
458
522
  baseUrl,
459
523
  user: currentUser,
460
524
  required: currentUser.passwordResetRequired,
461
- error: "New password and confirmation do not match.",
462
- locale,
525
+ error: createI18n(bodyLocale).t("password.mismatch"),
526
+ locale: bodyLocale,
463
527
  currentPath
464
528
  }), 400);
465
529
  }
@@ -471,8 +535,8 @@ export async function handleRequest({ request, response, store, host, port, secu
471
535
  return sendHtml(response, renderPasswordPage({
472
536
  baseUrl,
473
537
  user: updatedUser,
474
- success: "Password changed.",
475
- locale,
538
+ success: createI18n(bodyLocale).t("password.changed"),
539
+ locale: bodyLocale,
476
540
  currentPath
477
541
  }));
478
542
  } catch (error) {
@@ -553,6 +617,68 @@ export async function handleRequest({ request, response, store, host, port, secu
553
617
  }
554
618
 
555
619
  const browserAccess = accessContext(request, currentUser);
620
+ if (pathname === "/admin/email") {
621
+ if (!currentUser) throw Object.assign(new Error("An administrator browser session is required"), { statusCode: 401 });
622
+ requireAdmin(currentUser);
623
+ const emailPage = async (extra = {}) => renderAdminEmailPage({
624
+ baseUrl, user: currentUser, locale, currentPath: "/admin/email", settings: await getSmtpSettings(store), ...extra
625
+ });
626
+ if (method === "GET") return sendHtml(response, await emailPage(), 200, headOnly);
627
+ if (method === "POST") {
628
+ assertSameOrigin(request);
629
+ await enforceRateLimit({ store, host, request, bucket: "write" });
630
+ const body = await readFormBody(request, 16 * 1024);
631
+ try {
632
+ await saveSmtpSettings(store, {
633
+ enabled: body.enabled === "on" || body.enabled === "true", host: body.host || "", port: Number(body.port),
634
+ tlsMode: body.tlsMode, username: body.username || "", from: body.from || "", password: body.password || ""
635
+ }, { access: browserAccess, audit: { surface: "web", actor: currentUser.email, userId: currentUser.id } });
636
+ return sendRedirect(response, localizedHref("/admin/email", localeFromBodyOrUrl(body, url)));
637
+ } catch (error) {
638
+ if (error.statusCode !== 400) throw error;
639
+ return sendHtml(response, await emailPage({ error: createI18n(locale).t("smtp.invalidSettings") }), 400);
640
+ }
641
+ }
642
+ return sendJson(response, { error: "Method not allowed" }, 405, headOnly);
643
+ }
644
+ if (["/admin/languages", "/admin/languages/import", "/admin/languages/template"].includes(pathname)) {
645
+ if (!currentUser) throw Object.assign(new Error("An administrator browser session is required"), { statusCode: 401 });
646
+ requireAdmin(currentUser);
647
+ if (method === "GET" && pathname === "/admin/languages/template") {
648
+ response.setHeader("content-disposition", 'attachment; filename="artifacty-language-template.json"');
649
+ return sendJson(response, { locale: "en", nativeName: "English", messages: TRANSLATIONS.en }, 200, headOnly);
650
+ }
651
+ const renderLanguages = async (extra = {}) => renderAdminLanguagesPage({
652
+ baseUrl, user: currentUser, locale, currentPath: "/admin/languages",
653
+ settings: await getLanguageSettings(store), ...extra
654
+ });
655
+ if (method === "GET" && pathname === "/admin/languages") return sendHtml(response, await renderLanguages(), 200, headOnly);
656
+ if (method === "POST" && pathname !== "/admin/languages/template") {
657
+ assertSameOrigin(request);
658
+ await enforceRateLimit({ store, host, request, bucket: "write" });
659
+ try {
660
+ // URL encoding can use three bytes for each byte of the JSON text.
661
+ const body = await readFormBody(request, 3 * 512 * 1024 + 1024);
662
+ const options = { access: browserAccess, audit: { surface: "web", actor: currentUser.email, userId: currentUser.id } };
663
+ if (pathname.endsWith("/import")) {
664
+ if (Buffer.byteLength(String(body.pack || ""), "utf8") > 512 * 1024) {
665
+ throw Object.assign(new Error(createI18n(locale).t("languageAdmin.tooLarge")), { statusCode: 413 });
666
+ }
667
+ let pack;
668
+ try { pack = JSON.parse(body.pack); }
669
+ catch { throw Object.assign(new Error(createI18n(locale).t("languageAdmin.invalidJson")), { statusCode: 400 }); }
670
+ await importLanguagePack(store, pack, options);
671
+ } else {
672
+ await saveLanguageSettings(store, { defaultLocale: body.defaultLocale, enabledLocales: body.enabledLocales || [] }, options);
673
+ }
674
+ return sendRedirect(response, "/admin/languages");
675
+ } catch (error) {
676
+ if (![400, 413].includes(error.statusCode)) throw error;
677
+ return sendHtml(response, await renderLanguages({ error: error.message }), error.statusCode);
678
+ }
679
+ }
680
+ return sendJson(response, { error: "Method not allowed" }, 405, headOnly);
681
+ }
556
682
  if (["/admin/update", "/admin/update/status", "/admin/update/check", "/admin/update/start"].includes(pathname)) {
557
683
  if (!currentUser) throw Object.assign(new Error("An administrator browser session is required"), { statusCode: 401 });
558
684
  requireAdmin(currentUser);
@@ -591,20 +717,27 @@ export async function handleRequest({ request, response, store, host, port, secu
591
717
  if (!currentUser) return sendRedirect(response, "/login");
592
718
  requireAdmin(currentUser);
593
719
  if (method === "GET" && pathname === "/admin/access") {
594
- return sendHtml(response, await adminAccessPage(), 200, headOnly);
720
+ const selectedMode = url.searchParams.get("mode");
721
+ const preview = ["public", "private"].includes(selectedMode)
722
+ ? await previewAccessMode(store, selectedMode, { access: browserAccess }) : null;
723
+ return sendHtml(response, await adminAccessPage({ preview }), 200, headOnly);
595
724
  }
596
725
  if (method === "POST") {
597
726
  assertLocalOrigin(request);
727
+ if (pathname.startsWith("/admin/access")) assertSameOrigin(request);
598
728
  request.artifactyAuth = await requireBrowserWriteAuth({ store, request, url, config: security, currentUser });
599
729
  await enforceRateLimit({ store, host, request, bucket: "write" });
600
730
  const body = await readFormBody(request);
601
731
  const options = { access: browserAccess, audit: browserAudit, remoteAddress: request.socket.remoteAddress };
602
732
  if (pathname === "/admin/access/preview") {
603
- return sendHtml(response, await adminAccessPage({ preview: await previewAccessMode(store, body.mode, options) }), 200, headOnly);
733
+ return sendHtml(response, renderAccessModePreviewPage({
734
+ baseUrl, user: currentUser, locale: localeFromBodyOrUrl(body, url), currentPath: "/admin/access/preview",
735
+ ...await previewAccessMode(store, body.mode, options)
736
+ }), 200, headOnly);
604
737
  } else if (pathname === "/admin/access") {
605
738
  await setAccessMode(store, body.mode, options);
606
739
  } else if (pathname === "/admin/access/network") {
607
- await stageNetworkPolicy(store, { allowedCidrs: body.allowedCidrs || "" }, options);
740
+ await stageNetworkPolicy(store, { allowedCidrs: body.allowedCidrs || "", allowSharedLinks: body.allowSharedLinks === "true" || body.allowSharedLinks === "on" }, options);
608
741
  } else if (pathname === "/admin/access/network/confirm") {
609
742
  await confirmNetworkPolicy(store, body.id, options);
610
743
  } else if (pathname === "/admin/access/network/rollback") {
@@ -811,6 +944,67 @@ export async function handleRequest({ request, response, store, host, port, secu
811
944
  }
812
945
  }
813
946
 
947
+ const userPasswordResetMatch = /^\/admin\/users\/([^/]+)\/reset-password$/.exec(pathname);
948
+ if (userPasswordResetMatch && method === "POST") {
949
+ if (!currentUser) throw Object.assign(new Error("An administrator browser session is required"), { statusCode: 401 });
950
+ requireAdmin(currentUser);
951
+ assertSameOrigin(request);
952
+ await enforceRateLimit({ store, host, request, bucket: "write" });
953
+ const body = await readFormBody(request);
954
+ const resetResult = await resetUserPassword(store, userPasswordResetMatch[1], {
955
+ access: browserAccess, audit: { surface: "web", actor: currentUser.email, userId: currentUser.id }
956
+ });
957
+ resetResult.mailStatus = "not-configured";
958
+ try {
959
+ const smtp = await getSmtpDeliveryConfig(store);
960
+ if (smtp.enabled) {
961
+ const messages = createI18n(localeFromBodyOrUrl(body, url));
962
+ await smtpSender(smtp, {
963
+ to: resetResult.user.email,
964
+ subject: messages.t("smtp.resetSubject"),
965
+ text: messages.t("smtp.resetBody", { email: resetResult.user.email, password: resetResult.temporaryPassword })
966
+ });
967
+ resetResult.mailStatus = "sent";
968
+ }
969
+ } catch {
970
+ // Reset already succeeded. Keep the one-time password available for
971
+ // manual delivery, including when SMTP acceptance is uncertain.
972
+ resetResult.mailStatus = "failed";
973
+ }
974
+ response.setHeader("cache-control", "no-store");
975
+ return sendHtml(response, renderAdminUsersPage({
976
+ baseUrl, user: currentUser, users: await listUsers(store), resetResult,
977
+ locale: localeFromBodyOrUrl(body, url), currentPath: "/admin/users"
978
+ }));
979
+ }
980
+
981
+ const userRoleMatch = /^\/admin\/users\/([^/]+)\/role$/.exec(pathname);
982
+ if (userRoleMatch && method === "POST") {
983
+ if (!currentUser) throw Object.assign(new Error("An administrator browser session is required"), { statusCode: 401 });
984
+ requireAdmin(currentUser);
985
+ assertLocalOrigin(request);
986
+ if (request.headers.origin !== `${request.socket.encrypted ? "https" : "http"}://${request.headers.host}`) {
987
+ throw Object.assign(new Error("An exact same-origin request is required"), { statusCode: 403, code: "ROLE_ORIGIN_REQUIRED" });
988
+ }
989
+ await enforceRateLimit({ store, host, request, bucket: "write" });
990
+ const body = await readFormBody(request);
991
+ const bodyLocale = localeFromBodyOrUrl(body, url);
992
+ try {
993
+ await setUserRole(store, userRoleMatch[1], body.role, {
994
+ access: browserAccess, audit: { surface: "web", actor: currentUser.email, userId: currentUser.id }
995
+ });
996
+ const destination = userRoleMatch[1] === currentUser.id && body.role === "user" ? "/account" : "/admin/users";
997
+ return sendRedirect(response, localizedHref(destination, bodyLocale));
998
+ } catch (error) {
999
+ if (![400, 409].includes(error.statusCode)) throw error;
1000
+ return sendHtml(response, renderAdminUsersPage({
1001
+ baseUrl, user: currentUser, users: await listUsers(store), locale: bodyLocale, currentPath: "/admin/users",
1002
+ roleError: error.code === "LAST_ADMIN_REQUIRED" ? createI18n(bodyLocale).t("users.lastAdmin")
1003
+ : error.code === "INVALID_USER_ROLE" ? createI18n(bodyLocale).t("users.invalidRole") : error.message
1004
+ }), error.statusCode);
1005
+ }
1006
+ }
1007
+
814
1008
  const userActiveMatch = /^\/admin\/users\/([^/]+)\/(enable|disable)$/.exec(pathname);
815
1009
  if (userActiveMatch && method === "POST") {
816
1010
  if (!currentUser) {
@@ -1932,7 +2126,7 @@ export async function readFormBody(request, limitBytes = MAX_ARTIFACT_BYTES + 10
1932
2126
  const params = new URLSearchParams(raw);
1933
2127
  const body = Object.fromEntries(params.entries());
1934
2128
  // Sharing checkboxes submit repeated keys; scalar form fields keep their existing shape.
1935
- for (const key of ["userIds", "teamIds"]) {
2129
+ for (const key of ["userIds", "teamIds", "enabledLocales"]) {
1936
2130
  if (params.has(key)) body[key] = params.getAll(key);
1937
2131
  }
1938
2132
  return body;
@@ -1954,6 +2148,13 @@ export async function readBody(request, limitBytes) {
1954
2148
  return Buffer.concat(chunks).toString("utf8");
1955
2149
  }
1956
2150
 
2151
+ function assertSameOrigin(request) {
2152
+ const expected = `${request.socket.encrypted ? "https" : "http"}://${request.headers.host}`;
2153
+ if (request.headers.origin !== expected) {
2154
+ throw Object.assign(new Error("An exact same-origin request is required"), { statusCode: 403, code: "SAME_ORIGIN_REQUIRED" });
2155
+ }
2156
+ }
2157
+
1957
2158
  export function assertLocalOrigin(request) {
1958
2159
  const origin = request.headers.origin;
1959
2160
  if (!origin) {
@@ -2200,6 +2401,10 @@ function javascriptCorsHeaders(request) {
2200
2401
  }
2201
2402
 
2202
2403
  export function sendRedirect(response, location, headers = {}) {
2404
+ const existingCookies = response.getHeader("set-cookie");
2405
+ if (existingCookies && headers["set-cookie"]) {
2406
+ headers = { ...headers, "set-cookie": [...[].concat(existingCookies), ...[].concat(headers["set-cookie"])] };
2407
+ }
2203
2408
  response.writeHead(303, {
2204
2409
  location,
2205
2410
  "cache-control": "no-store",
@@ -2776,7 +2981,7 @@ function parseCookies(header) {
2776
2981
  const key = part.slice(0, index).trim();
2777
2982
  const value = part.slice(index + 1).trim();
2778
2983
  if (key) {
2779
- cookies[key] = decodeURIComponent(value);
2984
+ try { cookies[key] = decodeURIComponent(value); } catch { /* Ignore malformed cookies. */ }
2780
2985
  }
2781
2986
  }
2782
2987
  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; }