artifacty 0.1.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 ADDED
@@ -0,0 +1,576 @@
1
+ #!/usr/bin/env node
2
+ import http from "node:http";
3
+ import path from "node:path";
4
+ import { readFile } from "node:fs/promises";
5
+ import { URL } from "node:url";
6
+ import { fileURLToPath } from "node:url";
7
+ import {
8
+ archiveArtifact,
9
+ createArtifact,
10
+ createStore,
11
+ getArtifact,
12
+ listArtifacts,
13
+ listAuditEvents,
14
+ MAX_ARTIFACT_BYTES,
15
+ restoreArtifact,
16
+ updateArtifact
17
+ } from "./lib/storage.js";
18
+ import { convertAgentArtifact } from "./lib/converters.js";
19
+ import { createLineDiff } from "./lib/diff.js";
20
+ import { EDITOR_CLIENT_PATH, editorClientFilePath, editorVendorPath } from "./lib/editor-assets.js";
21
+ import { localeFromBodyOrUrl, localeFromUrl, localizedHref } from "./lib/i18n.js";
22
+ import { requireToken, securityConfig, validateServerExposure } from "./lib/security.js";
23
+ import { writeServerState } from "./lib/server-state.js";
24
+ import {
25
+ renderArtifactFormPage,
26
+ renderArtifactPage,
27
+ renderDashboard,
28
+ renderDiffPage,
29
+ renderImportArtifactPage,
30
+ renderNewArtifactPage
31
+ } from "./lib/render.js";
32
+
33
+ const DEFAULT_HOST = "127.0.0.1";
34
+ const DEFAULT_PORT = 8787;
35
+ const FALLBACK_PORT_ATTEMPTS = 10;
36
+ const PACKAGE_ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
37
+
38
+ export async function startServer(options = {}) {
39
+ const host = options.host || process.env.ARTIFACTY_HOST || DEFAULT_HOST;
40
+ const explicitPort = options.port !== undefined || process.env.ARTIFACTY_PORT !== undefined;
41
+ const requestedPort = Number(options.port ?? process.env.ARTIFACTY_PORT ?? DEFAULT_PORT);
42
+ const allowPortFallback =
43
+ options.portFallback === true ||
44
+ (!explicitPort && options.portFallback !== false);
45
+ const store = createStore({ home: options.home });
46
+ const security = securityConfig(options);
47
+ validateServerExposure({ host, config: security });
48
+
49
+ const { server, actualPort } = await listenWithFallback({
50
+ host,
51
+ requestedPort,
52
+ allowPortFallback,
53
+ createServer(port) {
54
+ const candidateServer = http.createServer((request, response) => {
55
+ handleRequest({ request, response, store, host, port: candidateServer.address()?.port || port, security }).catch((error) => {
56
+ sendError(response, error);
57
+ });
58
+ });
59
+ return candidateServer;
60
+ }
61
+ });
62
+
63
+ const url = `http://${host}:${actualPort}`;
64
+ const usedPortFallback = requestedPort !== 0 && actualPort !== requestedPort;
65
+ await writeServerState(store, {
66
+ url,
67
+ host,
68
+ port: actualPort,
69
+ requestedPort,
70
+ portFallback: usedPortFallback
71
+ });
72
+ return {
73
+ server,
74
+ store,
75
+ url,
76
+ requestedPort,
77
+ port: actualPort,
78
+ portFallback: usedPortFallback,
79
+ close: () => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())))
80
+ };
81
+ }
82
+
83
+ async function listenWithFallback({ host, requestedPort, allowPortFallback, createServer }) {
84
+ const candidates = portCandidates(requestedPort, allowPortFallback);
85
+ let lastError;
86
+
87
+ for (const port of candidates) {
88
+ const server = createServer(port);
89
+ try {
90
+ await listenOnce(server, port, host);
91
+ return {
92
+ server,
93
+ actualPort: server.address().port
94
+ };
95
+ } catch (error) {
96
+ lastError = error;
97
+ await closeServer(server);
98
+ if (!allowPortFallback || error.code !== "EADDRINUSE") {
99
+ throw error;
100
+ }
101
+ }
102
+ }
103
+
104
+ throw lastError;
105
+ }
106
+
107
+ function portCandidates(port, allowPortFallback) {
108
+ if (!allowPortFallback || port === 0) {
109
+ return [port];
110
+ }
111
+ return [
112
+ port,
113
+ ...Array.from({ length: FALLBACK_PORT_ATTEMPTS }, (_, index) => port + index + 1),
114
+ 0
115
+ ];
116
+ }
117
+
118
+ function listenOnce(server, port, host) {
119
+ return new Promise((resolve, reject) => {
120
+ server.once("error", reject);
121
+ server.listen(port, host, () => {
122
+ server.off("error", reject);
123
+ resolve();
124
+ });
125
+ });
126
+ }
127
+
128
+ function closeServer(server) {
129
+ return new Promise((resolve) => {
130
+ if (!server.listening) {
131
+ resolve();
132
+ return;
133
+ }
134
+ server.close(() => resolve());
135
+ });
136
+ }
137
+
138
+ export async function handleRequest({ request, response, store, host, port, security = securityConfig() }) {
139
+ const url = new URL(request.url, `http://${request.headers.host || `${host}:${port}`}`);
140
+ const pathname = decodeURIComponent(url.pathname);
141
+ const baseUrl = `http://${host}:${port}`;
142
+ const authToken = url.searchParams.get("token") || "";
143
+ const locale = localeFromUrl(url);
144
+ const currentPath = `${url.pathname}${url.search}`;
145
+ const headOnly = request.method === "HEAD";
146
+ const method = headOnly ? "GET" : request.method;
147
+
148
+ if (method === "GET" && pathname === EDITOR_CLIENT_PATH) {
149
+ return sendJavaScriptFile(response, editorClientFilePath(PACKAGE_ROOT), headOnly);
150
+ }
151
+
152
+ if (method === "GET" && pathname.startsWith("/vendor/npm/")) {
153
+ const packageName = pathname.slice("/vendor/npm/".length);
154
+ const vendorPath = editorVendorPath(packageName, PACKAGE_ROOT);
155
+ if (!vendorPath) {
156
+ return sendJson(response, { error: "Not found" }, 404, headOnly);
157
+ }
158
+ return sendJavaScriptFile(response, vendorPath, headOnly);
159
+ }
160
+
161
+ if (pathname.startsWith("/api/")) {
162
+ requireToken({ request, url, config: security });
163
+ }
164
+
165
+ if (method === "GET" && pathname === "/health") {
166
+ return sendJson(response, { ok: true, name: "artifacty", store: store.home }, 200, headOnly);
167
+ }
168
+
169
+ if (method === "GET" && pathname === "/") {
170
+ const filters = {
171
+ query: url.searchParams.get("q") || "",
172
+ tag: url.searchParams.get("tag") || "",
173
+ sourceAgent: url.searchParams.get("sourceAgent") || "",
174
+ includeArchived: url.searchParams.get("includeArchived") === "true"
175
+ };
176
+ const artifacts = await listArtifacts(store, {
177
+ query: filters.query || undefined,
178
+ tag: filters.tag || undefined,
179
+ sourceAgent: filters.sourceAgent || undefined,
180
+ includeArchived: filters.includeArchived
181
+ });
182
+ return sendHtml(response, renderDashboard({ artifacts, baseUrl, filters, locale, currentPath }), 200, headOnly);
183
+ }
184
+
185
+ if (method === "GET" && pathname === "/new") {
186
+ return sendHtml(response, renderNewArtifactPage({ baseUrl, authToken, locale, currentPath }), 200, headOnly);
187
+ }
188
+
189
+ if (method === "POST" && pathname === "/new") {
190
+ assertLocalOrigin(request);
191
+ const body = await readFormBody(request);
192
+ const bodyLocale = localeFromBodyOrUrl(body, url);
193
+ requireToken({ request, url, body, config: security });
194
+ const artifact = await createArtifact(store, {
195
+ title: body.title,
196
+ content: body.content,
197
+ format: body.format,
198
+ artifactType: body.artifactType,
199
+ sourceAgent: body.sourceAgent || "artifacty",
200
+ tags: splitTags(body.tags),
201
+ metadata: {
202
+ createdVia: "artifacty-web"
203
+ },
204
+ audit: auditContext(request, "web")
205
+ });
206
+ return sendRedirect(response, localizedHref(`/artifacts/${encodeURIComponent(artifact.id)}`, bodyLocale));
207
+ }
208
+
209
+ if (method === "GET" && pathname === "/import") {
210
+ return sendHtml(response, renderImportArtifactPage({ baseUrl, authToken, locale, currentPath }), 200, headOnly);
211
+ }
212
+
213
+ if (method === "POST" && pathname === "/import") {
214
+ assertLocalOrigin(request);
215
+ const body = await readFormBody(request);
216
+ const bodyLocale = localeFromBodyOrUrl(body, url);
217
+ requireToken({ request, url, body, config: security });
218
+ const converted = convertAgentArtifact({
219
+ agent: body.agent,
220
+ title: body.title,
221
+ content: body.content,
222
+ fileName: body.fileName,
223
+ tags: splitTags(body.tags),
224
+ metadata: {
225
+ createdVia: "artifacty-web-import"
226
+ }
227
+ });
228
+ const artifact = await createArtifact(store, {
229
+ ...converted,
230
+ auditAction: "import",
231
+ audit: auditContext(request, "web")
232
+ });
233
+ return sendRedirect(response, localizedHref(`/artifacts/${encodeURIComponent(artifact.id)}`, bodyLocale));
234
+ }
235
+
236
+ if (method === "GET" && pathname === "/api/artifacts") {
237
+ const artifacts = await listArtifacts(store, {
238
+ query: url.searchParams.get("q") || undefined,
239
+ tag: url.searchParams.get("tag") || undefined,
240
+ sourceAgent: url.searchParams.get("sourceAgent") || undefined,
241
+ includeArchived: url.searchParams.get("includeArchived") === "true",
242
+ limit: url.searchParams.get("limit") || undefined
243
+ });
244
+ return sendJson(response, { artifacts }, 200, headOnly);
245
+ }
246
+
247
+ if (method === "GET" && pathname === "/api/audit") {
248
+ const events = await listAuditEvents(store, {
249
+ artifactId: url.searchParams.get("artifactId") || undefined,
250
+ limit: url.searchParams.get("limit") || undefined
251
+ });
252
+ return sendJson(response, { events }, 200, headOnly);
253
+ }
254
+
255
+ if (method === "POST" && pathname === "/api/artifacts") {
256
+ assertLocalOrigin(request);
257
+ const body = await readJsonBody(request);
258
+ const artifact = await createArtifact(store, {
259
+ ...body,
260
+ audit: auditContext(request, "http-api")
261
+ });
262
+ return sendJson(response, decorateArtifactUrls(artifact, baseUrl), 201);
263
+ }
264
+
265
+ if (method === "POST" && pathname === "/api/import") {
266
+ assertLocalOrigin(request);
267
+ const body = await readJsonBody(request);
268
+ const converted = convertAgentArtifact(body);
269
+ const artifact = await createArtifact(store, {
270
+ ...converted,
271
+ auditAction: "import",
272
+ audit: auditContext(request, "http-api")
273
+ });
274
+ return sendJson(response, {
275
+ ...decorateArtifactUrls(artifact, baseUrl),
276
+ converted
277
+ }, 201);
278
+ }
279
+
280
+ const editMatch = /^\/artifacts\/([^/]+)\/edit$/.exec(pathname);
281
+ if (editMatch && method === "GET") {
282
+ const artifact = await getArtifact(store, editMatch[1], {
283
+ version: url.searchParams.get("version") || undefined
284
+ });
285
+ return sendHtml(response, renderArtifactFormPage({
286
+ mode: "edit",
287
+ baseUrl,
288
+ artifact,
289
+ version: artifact.version,
290
+ content: artifact.content,
291
+ authToken,
292
+ locale,
293
+ currentPath
294
+ }), 200, headOnly);
295
+ }
296
+
297
+ if (editMatch && method === "POST") {
298
+ assertLocalOrigin(request);
299
+ const body = await readFormBody(request);
300
+ const bodyLocale = localeFromBodyOrUrl(body, url);
301
+ requireToken({ request, url, body, config: security });
302
+ const artifact = await updateArtifact(store, editMatch[1], {
303
+ title: body.title,
304
+ content: body.content,
305
+ format: body.format,
306
+ artifactType: body.artifactType,
307
+ sourceAgent: body.sourceAgent || "artifacty",
308
+ tags: splitTags(body.tags),
309
+ metadata: {
310
+ updatedVia: "artifacty-web"
311
+ },
312
+ audit: auditContext(request, "web")
313
+ });
314
+ return sendRedirect(response, localizedHref(`/artifacts/${encodeURIComponent(artifact.id)}`, bodyLocale));
315
+ }
316
+
317
+ const diffMatch = /^\/artifacts\/([^/]+)\/diff$/.exec(pathname);
318
+ if (diffMatch && method === "GET") {
319
+ const latest = await getArtifact(store, diffMatch[1]);
320
+ const defaultFrom = Math.max(1, latest.latestVersion - 1);
321
+ const fromNumber = Number(url.searchParams.get("from") || defaultFrom);
322
+ const toNumber = Number(url.searchParams.get("to") || latest.latestVersion);
323
+ const from = await getArtifact(store, diffMatch[1], { version: fromNumber });
324
+ const to = await getArtifact(store, diffMatch[1], { version: toNumber });
325
+ return sendHtml(response, renderDiffPage({
326
+ artifact: latest,
327
+ fromVersion: from.version,
328
+ toVersion: to.version,
329
+ fromContent: from.content,
330
+ toContent: to.content,
331
+ diffRows: createLineDiff(from.content, to.content),
332
+ baseUrl,
333
+ authToken,
334
+ locale,
335
+ currentPath
336
+ }), 200, headOnly);
337
+ }
338
+
339
+ const archiveMatch = /^\/artifacts\/([^/]+)\/(archive|restore)$/.exec(pathname);
340
+ if (archiveMatch && method === "POST") {
341
+ assertLocalOrigin(request);
342
+ const body = await readFormBody(request);
343
+ const bodyLocale = localeFromBodyOrUrl(body, url);
344
+ requireToken({ request, url, body, config: security });
345
+ const artifact = archiveMatch[2] === "archive"
346
+ ? await archiveArtifact(store, archiveMatch[1], { audit: auditContext(request, "web") })
347
+ : await restoreArtifact(store, archiveMatch[1], { audit: auditContext(request, "web") });
348
+ return sendRedirect(response, localizedHref(`/artifacts/${encodeURIComponent(artifact.id)}`, bodyLocale));
349
+ }
350
+
351
+ const apiMatch = /^\/api\/artifacts\/([^/]+)$/.exec(pathname);
352
+ if (apiMatch && method === "GET") {
353
+ const artifact = await getArtifact(store, apiMatch[1], {
354
+ version: url.searchParams.get("version") || undefined
355
+ });
356
+ return sendJson(response, decorateArtifactUrls(artifact, baseUrl), 200, headOnly);
357
+ }
358
+
359
+ if (apiMatch && method === "POST") {
360
+ assertLocalOrigin(request);
361
+ const body = await readJsonBody(request);
362
+ const artifact = await updateArtifact(store, apiMatch[1], {
363
+ ...body,
364
+ audit: auditContext(request, "http-api")
365
+ });
366
+ return sendJson(response, decorateArtifactUrls(artifact, baseUrl));
367
+ }
368
+
369
+ const apiArchiveMatch = /^\/api\/artifacts\/([^/]+)\/(archive|restore)$/.exec(pathname);
370
+ if (apiArchiveMatch && method === "POST") {
371
+ assertLocalOrigin(request);
372
+ const artifact = apiArchiveMatch[2] === "archive"
373
+ ? await archiveArtifact(store, apiArchiveMatch[1], { audit: auditContext(request, "http-api") })
374
+ : await restoreArtifact(store, apiArchiveMatch[1], { audit: auditContext(request, "http-api") });
375
+ return sendJson(response, decorateArtifactUrls(artifact, baseUrl));
376
+ }
377
+
378
+ const artifactMatch = /^\/artifacts\/([^/]+)(?:\/raw)?$/.exec(pathname);
379
+ if (artifactMatch && method === "GET") {
380
+ const artifact = await getArtifact(store, artifactMatch[1], {
381
+ version: url.searchParams.get("version") || undefined,
382
+ audit: auditContext(request, "browser")
383
+ });
384
+
385
+ if (pathname.endsWith("/raw")) {
386
+ response.writeHead(200, {
387
+ "content-type": artifact.version.contentType,
388
+ "cache-control": "no-store",
389
+ "x-content-type-options": "nosniff"
390
+ });
391
+ response.end(headOnly ? undefined : artifact.content);
392
+ return;
393
+ }
394
+
395
+ return sendHtml(response, renderArtifactPage({
396
+ artifact,
397
+ version: artifact.version,
398
+ content: artifact.content,
399
+ baseUrl,
400
+ authToken,
401
+ locale,
402
+ currentPath
403
+ }), 200, headOnly);
404
+ }
405
+
406
+ sendJson(response, { error: "Not found" }, 404);
407
+ }
408
+
409
+ export function decorateArtifactUrls(artifact, baseUrl) {
410
+ return {
411
+ ...artifact,
412
+ url: `${baseUrl}/artifacts/${encodeURIComponent(artifact.id)}`,
413
+ rawUrl: `${baseUrl}/artifacts/${encodeURIComponent(artifact.id)}/raw?version=${artifact.version.version}`
414
+ };
415
+ }
416
+
417
+ export async function readJsonBody(request) {
418
+ const raw = await readBody(request, MAX_ARTIFACT_BYTES + 1024);
419
+ if (!raw.trim()) {
420
+ return {};
421
+ }
422
+ try {
423
+ return JSON.parse(raw);
424
+ } catch (error) {
425
+ throw Object.assign(new Error(`Invalid JSON body: ${error.message}`), {
426
+ statusCode: 400,
427
+ code: "INVALID_JSON"
428
+ });
429
+ }
430
+ }
431
+
432
+ export async function readFormBody(request) {
433
+ const raw = await readBody(request, MAX_ARTIFACT_BYTES + 1024);
434
+ const params = new URLSearchParams(raw);
435
+ return Object.fromEntries(params.entries());
436
+ }
437
+
438
+ export async function readBody(request, limitBytes) {
439
+ const chunks = [];
440
+ let size = 0;
441
+ for await (const chunk of request) {
442
+ size += chunk.byteLength;
443
+ if (size > limitBytes) {
444
+ throw Object.assign(new Error("Request body too large"), {
445
+ statusCode: 413,
446
+ code: "REQUEST_TOO_LARGE"
447
+ });
448
+ }
449
+ chunks.push(chunk);
450
+ }
451
+ return Buffer.concat(chunks).toString("utf8");
452
+ }
453
+
454
+ export function assertLocalOrigin(request) {
455
+ const origin = request.headers.origin;
456
+ if (!origin) {
457
+ return;
458
+ }
459
+
460
+ const parsed = new URL(origin);
461
+ const hostname = parsed.hostname.toLowerCase();
462
+ const allowed = hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
463
+ if (!allowed) {
464
+ throw Object.assign(new Error(`Rejected non-local origin: ${origin}`), {
465
+ statusCode: 403,
466
+ code: "NON_LOCAL_ORIGIN"
467
+ });
468
+ }
469
+ }
470
+
471
+ export function sendJson(response, data, statusCode = 200, headOnly = false) {
472
+ response.writeHead(statusCode, {
473
+ "content-type": "application/json; charset=utf-8",
474
+ "cache-control": "no-store",
475
+ "x-content-type-options": "nosniff"
476
+ });
477
+ response.end(headOnly ? undefined : `${JSON.stringify(data, null, 2)}\n`);
478
+ }
479
+
480
+ export function sendHtml(response, html, statusCode = 200, headOnly = false) {
481
+ response.writeHead(statusCode, {
482
+ "content-type": "text/html; charset=utf-8",
483
+ "cache-control": "no-store",
484
+ "x-content-type-options": "nosniff",
485
+ "content-security-policy": [
486
+ "default-src 'self' data: blob:",
487
+ "frame-src 'self' data: blob:",
488
+ "img-src 'self' data: blob:",
489
+ "style-src 'self' 'unsafe-inline'",
490
+ "script-src 'self' 'unsafe-inline'",
491
+ "object-src 'none'",
492
+ "base-uri 'none'",
493
+ "form-action 'self'"
494
+ ].join("; ")
495
+ });
496
+ response.end(headOnly ? undefined : html);
497
+ }
498
+
499
+ export async function sendJavaScriptFile(response, filePath, headOnly = false) {
500
+ const content = headOnly ? "" : await readFile(filePath, "utf8");
501
+ response.writeHead(200, {
502
+ "content-type": "text/javascript; charset=utf-8",
503
+ "cache-control": "no-store",
504
+ "x-content-type-options": "nosniff"
505
+ });
506
+ response.end(headOnly ? undefined : content);
507
+ }
508
+
509
+ export function sendRedirect(response, location) {
510
+ response.writeHead(303, {
511
+ location,
512
+ "cache-control": "no-store"
513
+ });
514
+ response.end();
515
+ }
516
+
517
+ export function sendError(response, error) {
518
+ const statusCode = error.statusCode || 500;
519
+ const body = {
520
+ error: error.message,
521
+ code: error.code || "SERVER_ERROR"
522
+ };
523
+ if (error.findings) {
524
+ body.findings = error.findings;
525
+ }
526
+ sendJson(response, body, statusCode);
527
+ }
528
+
529
+ function splitTags(value) {
530
+ if (!value) {
531
+ return [];
532
+ }
533
+ return String(value)
534
+ .split(",")
535
+ .map((tag) => tag.trim())
536
+ .filter(Boolean);
537
+ }
538
+
539
+ function auditContext(request, surface) {
540
+ return {
541
+ surface,
542
+ actor: request.headers["x-artifacty-actor"] || request.headers["user-agent"] || "unknown"
543
+ };
544
+ }
545
+
546
+ function isMain(metaUrl) {
547
+ return process.argv[1] && metaUrl === new URL(`file://${process.argv[1]}`).href;
548
+ }
549
+
550
+ if (isMain(import.meta.url)) {
551
+ const options = parseServerArgs(process.argv.slice(2));
552
+ startServer(options)
553
+ .then(({ url, store }) => {
554
+ process.stderr.write(`Artifacty listening on ${url}\n`);
555
+ process.stderr.write(`Store: ${store.home}\n`);
556
+ })
557
+ .catch((error) => {
558
+ process.stderr.write(`${error.stack || error.message}\n`);
559
+ process.exitCode = 1;
560
+ });
561
+ }
562
+
563
+ function parseServerArgs(args) {
564
+ const options = {};
565
+ for (let index = 0; index < args.length; index += 1) {
566
+ const arg = args[index];
567
+ if (arg === "--host") {
568
+ options.host = args[++index];
569
+ } else if (arg === "--port") {
570
+ options.port = Number(args[++index]);
571
+ } else if (arg === "--home") {
572
+ options.home = args[++index];
573
+ }
574
+ }
575
+ return options;
576
+ }