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/README.md +28 -0
- package/docs/central-team-deployment-design.md +212 -0
- package/docs/integrations.md +39 -1
- package/docs/mcp-public-api.md +13 -4
- package/docs/network-sharing.md +13 -0
- package/docs/threat-model.md +20 -6
- package/package.json +2 -1
- package/src/cli.js +28 -6
- package/src/lib/background.js +3 -0
- package/src/lib/installer.js +31 -0
- package/src/lib/render.js +371 -1
- package/src/lib/security.js +4 -0
- package/src/lib/service.js +4 -0
- package/src/lib/storage.js +565 -1
- package/src/mcp-server.js +225 -81
- package/src/server.js +412 -13
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|