artifacty 0.10.2 → 0.10.3
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 +3 -3
- package/docs/artifact-schema-v1.md +10 -0
- package/docs/central-team-deployment-design.md +5 -4
- package/docs/integrations.md +1 -1
- package/docs/mcp-public-api.md +3 -2
- package/docs/threat-model.md +2 -2
- package/package.json +1 -1
- package/src/lib/i18n.js +4 -0
- package/src/lib/render.js +26 -2
- package/src/lib/storage.js +172 -12
- package/src/server.js +3 -2
package/README.md
CHANGED
|
@@ -314,7 +314,7 @@ The browser UI defaults to English. Add `?lang=ko` to any browser route to use K
|
|
|
314
314
|
|
|
315
315
|
Schema and storage:
|
|
316
316
|
|
|
317
|
-
- Metadata lives in SQLite with `schemaVersion: 1`, `artifactType`, and `archivedAt`.
|
|
317
|
+
- Metadata lives in SQLite with `schemaVersion: 1`, `artifactType`, `publisherId`, and `archivedAt`.
|
|
318
318
|
- Archive hides artifacts from default lists without deleting versions.
|
|
319
319
|
- Bundle artifacts store multiple files or base64 assets as portable JSON.
|
|
320
320
|
- Supported formats are `html`, `markdown`, `text`, `json`, `code`, `svg`, `mermaid`, `react`, `sarif`, `csv`, `image`, and `video`.
|
|
@@ -329,13 +329,13 @@ Schema and storage:
|
|
|
329
329
|
|
|
330
330
|
- The HTTP server binds to `127.0.0.1` by default.
|
|
331
331
|
- If `ARTIFACTY_API_TOKEN` is set, HTTP API routes require `Authorization: Bearer <token>` or `x-artifacty-token`; scripts should prefer headers over `?token=...` URLs.
|
|
332
|
-
- When users exist, personal API tokens issued from `/account` also authenticate HTTP API and MCP requests
|
|
332
|
+
- When users exist, personal API tokens issued from `/account` also authenticate HTTP API and MCP requests. Created artifacts record the token owner's email as `publisherId`, and audit logs record the same identity as `actor`.
|
|
333
333
|
- API token checks use timing-safe digest comparison.
|
|
334
334
|
- Binding outside localhost requires both `ARTIFACTY_SHARE_MODE=lan` or `team` and `ARTIFACTY_API_TOKEN`.
|
|
335
335
|
- Non-local sharing is intended for trusted LAN or VPN sessions. Prefer a specific interface IP over `0.0.0.0`, keep React rendering disabled, and see [docs/network-sharing.md](docs/network-sharing.md).
|
|
336
336
|
- Non-local binding prints a startup warning because Artifacty does not terminate TLS.
|
|
337
337
|
- Artifact content is scanned for common API keys and private keys before storage. Use `--allow-secrets` or `ARTIFACTY_ALLOW_SECRETS=true` only for intentional exceptions.
|
|
338
|
-
- Creates, updates, reads, imports, archives, and restores write audit events to SQLite.
|
|
338
|
+
- Creates, updates, reads, imports, archives, and restores write audit events to SQLite. Legacy artifacts without a stored publisher are best-effort backfilled from their first `create` or `import` audit actor.
|
|
339
339
|
- CodeMirror editor/viewer and renderer assets are served from local npm dependencies through a package allowlist, not from a public CDN. JavaScript asset routes answer `Origin: null` requests with `Access-Control-Allow-Origin: null` so sandboxed renderer iframes can import local ESM without `allow-same-origin`.
|
|
340
340
|
- Mutating HTTP routes reject non-local browser origins.
|
|
341
341
|
- HTML artifacts render in a sandboxed iframe.
|
|
@@ -11,6 +11,9 @@ Artifacty schema v1 defines the stable envelope shared by HTTP, CLI, MCP, conver
|
|
|
11
11
|
"artifactType": "document",
|
|
12
12
|
"title": "Readable title",
|
|
13
13
|
"sourceAgent": "codex",
|
|
14
|
+
"publisherId": "user@example.com",
|
|
15
|
+
"publisherName": "User Name",
|
|
16
|
+
"publisherUserId": "user-record-id",
|
|
14
17
|
"tags": ["handoff"],
|
|
15
18
|
"createdAt": "2026-06-24T00:00:00.000Z",
|
|
16
19
|
"updatedAt": "2026-06-24T00:00:00.000Z",
|
|
@@ -41,6 +44,13 @@ Allowed `artifactType` values:
|
|
|
41
44
|
|
|
42
45
|
Unknown legacy or external types should be mapped to `unknown`, not rejected during conversion. Native create/update rejects unsupported explicit types.
|
|
43
46
|
|
|
47
|
+
`sourceAgent` identifies the tool or model surface that produced the artifact,
|
|
48
|
+
such as `codex` or `claude`. `publisherId` identifies who published it to the
|
|
49
|
+
Artifacty store. For authenticated central servers this is the personal token
|
|
50
|
+
owner's email; `publisherName` and `publisherUserId` are included when the
|
|
51
|
+
server can map the request to a local user record. Legacy artifacts are
|
|
52
|
+
best-effort backfilled from the first `create` or `import` audit actor.
|
|
53
|
+
|
|
44
54
|
## Version Record
|
|
45
55
|
|
|
46
56
|
Each version is immutable and points at one content file.
|
|
@@ -126,7 +126,8 @@ revoke personal API tokens from `/account`. CSV imports support
|
|
|
126
126
|
`email,name,role,password,password_reset_required`; blank passwords are
|
|
127
127
|
generated once and require a password change on first sign-in. Use personal
|
|
128
128
|
tokens for `artifacty install ... --api-token` so MCP and API audit logs record
|
|
129
|
-
the user's email as `actor
|
|
129
|
+
the user's email as `actor` and created artifacts record the same email as
|
|
130
|
+
`publisherId`.
|
|
130
131
|
|
|
131
132
|
Production-like internal deployments should run Artifacty behind a TLS reverse
|
|
132
133
|
proxy, keep `ARTIFACTY_ENABLE_REACT_RENDERER` disabled unless the team trusts
|
|
@@ -162,9 +163,9 @@ x-artifacty-actor: user@example.com
|
|
|
162
163
|
x-artifacty-host: IRAE-MACBOOK
|
|
163
164
|
```
|
|
164
165
|
|
|
165
|
-
The server records the authenticated user's email as the
|
|
166
|
-
personal token is used. The `x-artifacty-actor` header
|
|
167
|
-
fallback for the bootstrap/global token path.
|
|
166
|
+
The server records the authenticated user's email as the artifact `publisherId`
|
|
167
|
+
and audit `actor` when a personal token is used. The `x-artifacty-actor` header
|
|
168
|
+
remains a compatibility fallback for the bootstrap/global token path.
|
|
168
169
|
|
|
169
170
|
## Security Model
|
|
170
171
|
|
package/docs/integrations.md
CHANGED
|
@@ -353,7 +353,7 @@ tests.
|
|
|
353
353
|
- `GET /artifacts/:id/diff`: compare two versions.
|
|
354
354
|
- `GET /artifacts/:id/raw?version=n`: raw content.
|
|
355
355
|
|
|
356
|
-
When `ARTIFACTY_API_TOKEN` is configured, `/api/*` routes require either `Authorization: Bearer <token>` or `x-artifacty-token: <token>`. When users exist, personal tokens issued from `/account` also authenticate API and MCP requests
|
|
356
|
+
When `ARTIFACTY_API_TOKEN` is configured, `/api/*` routes require either `Authorization: Bearer <token>` or `x-artifacty-token: <token>`. When users exist, personal tokens issued from `/account` also authenticate API and MCP requests, set created artifacts' `publisherId` to the token owner's email, and map audit `actor` to the same identity. Browser forms can also carry `?token=<token>` in the URL, which is copied to hidden form fields for local team workflows.
|
|
357
357
|
|
|
358
358
|
Renderer notes:
|
|
359
359
|
|
package/docs/mcp-public-api.md
CHANGED
|
@@ -66,8 +66,9 @@ Prompts accept optional context arguments such as `artifactId`, `goal`, `scope`,
|
|
|
66
66
|
- Local stdio remains the default. Enable the central HTTP endpoint with
|
|
67
67
|
`artifacty serve --mcp-http` and install bridge mode with
|
|
68
68
|
`artifacty install <agent> --mcp-url http://host:8787/mcp --api-token <token>`.
|
|
69
|
-
- On central servers, use a personal token from `/account` so
|
|
70
|
-
the token owner's email as
|
|
69
|
+
- On central servers, use a personal token from `/account` so created artifacts
|
|
70
|
+
include the token owner's email as `publisherId` and audit logs record the
|
|
71
|
+
same identity as `actor`.
|
|
71
72
|
- Remote MCP currently uses bearer/header token auth. OAuth and per-user scoped
|
|
72
73
|
tokens are future hardening work.
|
|
73
74
|
- Binary media resources return stored base64 text through MCP; browser `/raw`
|
package/docs/threat-model.md
CHANGED
|
@@ -114,8 +114,8 @@ Controls:
|
|
|
114
114
|
- The HTTP MCP endpoint is disabled unless explicitly enabled.
|
|
115
115
|
- Remote MCP requests require the configured API token.
|
|
116
116
|
- Stdio bridge mode sends tokens in headers, not URLs.
|
|
117
|
-
- Personal tokens map requests to a server-side user record for
|
|
118
|
-
attribution.
|
|
117
|
+
- Personal tokens map requests to a server-side user record for artifact
|
|
118
|
+
`publisherId` and audit `actor` attribution.
|
|
119
119
|
- MCP writes go through the same secret scan and audit paths as CLI/HTTP writes.
|
|
120
120
|
- MCP resources are read-only.
|
|
121
121
|
|
package/package.json
CHANGED
package/src/lib/i18n.js
CHANGED
|
@@ -45,6 +45,8 @@ const TRANSLATIONS = {
|
|
|
45
45
|
"artifact.schema": "schema v{version}",
|
|
46
46
|
"artifact.archived": "archived {date}",
|
|
47
47
|
"artifact.bytes": "{size} bytes",
|
|
48
|
+
"artifact.publisher": "publisher {publisher}",
|
|
49
|
+
"artifact.publisherUnknown": "unknown publisher",
|
|
48
50
|
"artifact.archive": "Archive",
|
|
49
51
|
"artifact.restore": "Restore",
|
|
50
52
|
"diff.title": "{title} Diff",
|
|
@@ -103,6 +105,8 @@ const TRANSLATIONS = {
|
|
|
103
105
|
"artifact.schema": "스키마 v{version}",
|
|
104
106
|
"artifact.archived": "보관됨 {date}",
|
|
105
107
|
"artifact.bytes": "{size} 바이트",
|
|
108
|
+
"artifact.publisher": "게시자 {publisher}",
|
|
109
|
+
"artifact.publisherUnknown": "게시자 알 수 없음",
|
|
106
110
|
"artifact.archive": "보관",
|
|
107
111
|
"artifact.restore": "복원",
|
|
108
112
|
"diff.title": "{title} 비교",
|
package/src/lib/render.js
CHANGED
|
@@ -13,6 +13,7 @@ export function renderDashboard({ artifacts, baseUrl, filters = {}, pagination,
|
|
|
13
13
|
.map((artifact) => {
|
|
14
14
|
const tags = artifact.tags.map((tag) => `<span class="tag">${escapeHtml(tag)}</span>`).join("");
|
|
15
15
|
const status = artifact.archivedAt ? statusBadge("archived") : "";
|
|
16
|
+
const publisher = publisherDisplay(artifact, view);
|
|
16
17
|
const snippet = artifact.searchSnippet
|
|
17
18
|
? `<span class="row-snippet">${escapeHtml(artifact.searchSnippet)}</span>`
|
|
18
19
|
: "";
|
|
@@ -24,6 +25,7 @@ export function renderDashboard({ artifacts, baseUrl, filters = {}, pagination,
|
|
|
24
25
|
${snippet}
|
|
25
26
|
</span>
|
|
26
27
|
<span>${escapeHtml(artifact.sourceAgent)}</span>
|
|
28
|
+
<span class="publisher-cell" title="${escapeAttribute(publisherTitle(artifact, view))}">${escapeHtml(publisher)}</span>
|
|
27
29
|
${typeBadge(artifact.artifactType || "document")}
|
|
28
30
|
${formatBadge(artifact.format || "text")}
|
|
29
31
|
<span>v${artifact.latestVersion}</span>
|
|
@@ -527,6 +529,7 @@ export function renderArtifactPage({ artifact, version, content, baseUrl, authTo
|
|
|
527
529
|
const rawUrl = `/artifacts/${encodeURIComponent(artifact.id)}/raw?version=${version.version}`;
|
|
528
530
|
const archiveAction = artifact.archivedAt ? "restore" : "archive";
|
|
529
531
|
const archiveLabel = artifact.archivedAt ? view.text("artifact.restore") : view.text("artifact.archive");
|
|
532
|
+
const publisher = publisherDisplay(artifact, view);
|
|
530
533
|
|
|
531
534
|
return pageShell({
|
|
532
535
|
title: artifact.title,
|
|
@@ -550,6 +553,7 @@ export function renderArtifactPage({ artifact, version, content, baseUrl, authTo
|
|
|
550
553
|
${typeBadge(artifact.artifactType || "document")}
|
|
551
554
|
<span>${view.text("artifact.schema", { version: artifact.schemaVersion || 1 })}</span>
|
|
552
555
|
${artifact.archivedAt ? statusBadge(view.text("artifact.archived", { date: artifact.archivedAt })) : ""}
|
|
556
|
+
<span title="${escapeAttribute(publisherTitle(artifact, view))}">${view.text("artifact.publisher", { publisher })}</span>
|
|
553
557
|
<span>${view.text("artifact.bytes", { size: version.sizeBytes })}</span>
|
|
554
558
|
<span>${escapeHtml(version.createdAt)}</span>
|
|
555
559
|
<span>${escapeHtml(version.sha256.slice(0, 12))}</span>
|
|
@@ -1295,8 +1299,8 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
|
|
|
1295
1299
|
}
|
|
1296
1300
|
.artifact-row {
|
|
1297
1301
|
display: grid;
|
|
1298
|
-
grid-template-columns: minmax(220px, 1fr)
|
|
1299
|
-
gap:
|
|
1302
|
+
grid-template-columns: minmax(220px, 1fr) 110px minmax(140px, 200px) 110px 84px 56px minmax(100px, 180px);
|
|
1303
|
+
gap: 12px;
|
|
1300
1304
|
align-items: center;
|
|
1301
1305
|
min-height: 64px;
|
|
1302
1306
|
padding: 14px 18px;
|
|
@@ -1343,6 +1347,13 @@ export function pageShell({ title, body, head = "", afterBody = "", locale = DEF
|
|
|
1343
1347
|
font-size: 12.5px;
|
|
1344
1348
|
color: var(--muted);
|
|
1345
1349
|
}
|
|
1350
|
+
.artifact-row .publisher-cell {
|
|
1351
|
+
min-width: 0;
|
|
1352
|
+
overflow: hidden;
|
|
1353
|
+
text-overflow: ellipsis;
|
|
1354
|
+
white-space: nowrap;
|
|
1355
|
+
color: var(--text);
|
|
1356
|
+
}
|
|
1346
1357
|
.empty {
|
|
1347
1358
|
color: var(--muted);
|
|
1348
1359
|
font-size: 13px;
|
|
@@ -1828,6 +1839,19 @@ function formatBadge(value) {
|
|
|
1828
1839
|
return `<span class="badge f-${escapeAttribute(slug)}">${escapeHtml(format)}</span>`;
|
|
1829
1840
|
}
|
|
1830
1841
|
|
|
1842
|
+
function publisherDisplay(artifact, view) {
|
|
1843
|
+
return artifact.publisherId || artifact.publisherName || view.text("artifact.publisherUnknown");
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
function publisherTitle(artifact, view) {
|
|
1847
|
+
const id = artifact.publisherId || "";
|
|
1848
|
+
const name = artifact.publisherName || "";
|
|
1849
|
+
if (id && name && id !== name) {
|
|
1850
|
+
return `${name} <${id}>`;
|
|
1851
|
+
}
|
|
1852
|
+
return id || name || view.text("artifact.publisherUnknown");
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1831
1855
|
function statusBadge(label) {
|
|
1832
1856
|
return `<span class="badge s-archived">${label}</span>`;
|
|
1833
1857
|
}
|
package/src/lib/storage.js
CHANGED
|
@@ -6,7 +6,7 @@ import path from "node:path";
|
|
|
6
6
|
import { DatabaseSync } from "node:sqlite";
|
|
7
7
|
import { assertNoSecrets, securityConfig } from "./security.js";
|
|
8
8
|
|
|
9
|
-
export const STORE_VERSION =
|
|
9
|
+
export const STORE_VERSION = 4;
|
|
10
10
|
export const ARTIFACT_SCHEMA_VERSION = 1;
|
|
11
11
|
export const MAX_ARTIFACT_BYTES = 16 * 1024 * 1024;
|
|
12
12
|
export const USER_ROLES = ["admin", "user"];
|
|
@@ -141,6 +141,7 @@ export async function createArtifact(store = createStore(), input = {}) {
|
|
|
141
141
|
const now = new Date().toISOString();
|
|
142
142
|
const id = makeArtifactId(normalized.title);
|
|
143
143
|
const version = writeVersionFile(store, id, 1, normalized, now);
|
|
144
|
+
const publisher = publisherFromAudit(input.audit);
|
|
144
145
|
|
|
145
146
|
artifact = {
|
|
146
147
|
id,
|
|
@@ -148,6 +149,9 @@ export async function createArtifact(store = createStore(), input = {}) {
|
|
|
148
149
|
artifactType: normalized.artifactType,
|
|
149
150
|
schemaVersion: normalized.schemaVersion,
|
|
150
151
|
sourceAgent: normalized.sourceAgent,
|
|
152
|
+
publisherId: publisher.publisherId,
|
|
153
|
+
publisherName: publisher.publisherName,
|
|
154
|
+
publisherUserId: publisher.publisherUserId,
|
|
151
155
|
tags: normalized.tags,
|
|
152
156
|
createdAt: now,
|
|
153
157
|
updatedAt: now,
|
|
@@ -461,15 +465,17 @@ function listArtifactsPageWithSql(db, filters) {
|
|
|
461
465
|
LOWER(id) LIKE ? ESCAPE '\\' OR
|
|
462
466
|
LOWER(title) LIKE ? ESCAPE '\\' OR
|
|
463
467
|
LOWER(source_agent) LIKE ? ESCAPE '\\' OR
|
|
468
|
+
LOWER(COALESCE(publisher_id, '')) LIKE ? ESCAPE '\\' OR
|
|
469
|
+
LOWER(COALESCE(publisher_name, '')) LIKE ? ESCAPE '\\' OR
|
|
464
470
|
LOWER(tags_json) LIKE ? ESCAPE '\\'
|
|
465
471
|
)`);
|
|
466
|
-
params.push(like, like, like, like);
|
|
472
|
+
params.push(like, like, like, like, like, like);
|
|
467
473
|
}
|
|
468
474
|
|
|
469
475
|
const whereSql = clauses.length ? `WHERE ${clauses.join(" AND ")}` : "";
|
|
470
476
|
const total = db.prepare(`SELECT COUNT(*) AS total FROM artifacts ${whereSql}`).get(...params).total;
|
|
471
477
|
const rows = db.prepare(`
|
|
472
|
-
SELECT id, title, artifact_type, schema_version, source_agent, tags_json, created_at, updated_at, latest_version, archived_at
|
|
478
|
+
SELECT id, title, artifact_type, schema_version, source_agent, publisher_id, publisher_name, publisher_user_id, tags_json, created_at, updated_at, latest_version, archived_at
|
|
473
479
|
FROM artifacts
|
|
474
480
|
${whereSql}
|
|
475
481
|
ORDER BY updated_at DESC, created_at DESC
|
|
@@ -503,13 +509,16 @@ function listArtifactsPageWithFts(db, filters) {
|
|
|
503
509
|
a.artifact_type,
|
|
504
510
|
a.schema_version,
|
|
505
511
|
a.source_agent,
|
|
512
|
+
a.publisher_id,
|
|
513
|
+
a.publisher_name,
|
|
514
|
+
a.publisher_user_id,
|
|
506
515
|
a.tags_json,
|
|
507
516
|
a.created_at,
|
|
508
517
|
a.updated_at,
|
|
509
518
|
a.latest_version,
|
|
510
519
|
a.archived_at,
|
|
511
520
|
bm25(artifact_search) AS search_rank,
|
|
512
|
-
snippet(artifact_search,
|
|
521
|
+
snippet(artifact_search, -1, '', '', '...', 24) AS search_snippet
|
|
513
522
|
FROM artifact_search
|
|
514
523
|
JOIN artifacts a ON a.id = artifact_search.artifact_id
|
|
515
524
|
${whereSql}
|
|
@@ -572,6 +581,9 @@ function artifactFromRow(db, row) {
|
|
|
572
581
|
artifactType: row.artifact_type,
|
|
573
582
|
schemaVersion: row.schema_version,
|
|
574
583
|
sourceAgent: row.source_agent,
|
|
584
|
+
publisherId: row.publisher_id || null,
|
|
585
|
+
publisherName: row.publisher_name || null,
|
|
586
|
+
publisherUserId: row.publisher_user_id || null,
|
|
575
587
|
tags: parseJson(row.tags_json, []),
|
|
576
588
|
createdAt: row.created_at,
|
|
577
589
|
updatedAt: row.updated_at,
|
|
@@ -589,6 +601,15 @@ function normalizeWhitespace(value) {
|
|
|
589
601
|
return normalizeOptionalString(value).replace(/\s+/g, " ");
|
|
590
602
|
}
|
|
591
603
|
|
|
604
|
+
function publisherFromAudit(audit = {}) {
|
|
605
|
+
const publisherId = normalizeOptionalString(audit.publisherId || audit.actor) || null;
|
|
606
|
+
return {
|
|
607
|
+
publisherId,
|
|
608
|
+
publisherName: normalizeOptionalString(audit.publisherName || audit.userName) || null,
|
|
609
|
+
publisherUserId: normalizeOptionalString(audit.publisherUserId || audit.userId) || null
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
|
|
592
613
|
function toFtsQuery(value) {
|
|
593
614
|
const tokens = normalizeOptionalString(value).match(/[\p{L}\p{N}_-]+/gu) || [];
|
|
594
615
|
return tokens
|
|
@@ -986,6 +1007,9 @@ export function toArtifactSummary(artifact) {
|
|
|
986
1007
|
artifactType: artifact.artifactType,
|
|
987
1008
|
schemaVersion: artifact.schemaVersion,
|
|
988
1009
|
sourceAgent: artifact.sourceAgent,
|
|
1010
|
+
publisherId: artifact.publisherId || null,
|
|
1011
|
+
publisherName: artifact.publisherName || null,
|
|
1012
|
+
publisherUserId: artifact.publisherUserId || null,
|
|
989
1013
|
tags: artifact.tags,
|
|
990
1014
|
createdAt: artifact.createdAt,
|
|
991
1015
|
updatedAt: artifact.updatedAt,
|
|
@@ -1075,6 +1099,9 @@ function initializeSchema(db) {
|
|
|
1075
1099
|
artifact_type TEXT NOT NULL DEFAULT 'document',
|
|
1076
1100
|
schema_version INTEGER NOT NULL DEFAULT 1,
|
|
1077
1101
|
source_agent TEXT NOT NULL,
|
|
1102
|
+
publisher_id TEXT,
|
|
1103
|
+
publisher_name TEXT,
|
|
1104
|
+
publisher_user_id TEXT,
|
|
1078
1105
|
tags_json TEXT NOT NULL,
|
|
1079
1106
|
created_at TEXT NOT NULL,
|
|
1080
1107
|
updated_at TEXT NOT NULL,
|
|
@@ -1152,11 +1179,118 @@ function initializeSchema(db) {
|
|
|
1152
1179
|
ensureColumn(db, "artifacts", "artifact_type", "TEXT NOT NULL DEFAULT 'document'");
|
|
1153
1180
|
ensureColumn(db, "artifacts", "schema_version", "INTEGER NOT NULL DEFAULT 1");
|
|
1154
1181
|
ensureColumn(db, "artifacts", "archived_at", "TEXT");
|
|
1182
|
+
ensureColumn(db, "artifacts", "publisher_id", "TEXT");
|
|
1183
|
+
ensureColumn(db, "artifacts", "publisher_name", "TEXT");
|
|
1184
|
+
ensureColumn(db, "artifacts", "publisher_user_id", "TEXT");
|
|
1155
1185
|
ensureColumn(db, "users", "password_reset_required", "INTEGER NOT NULL DEFAULT 0");
|
|
1186
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_artifacts_publisher_id ON artifacts(publisher_id)");
|
|
1187
|
+
backfillArtifactPublishers(db);
|
|
1156
1188
|
db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('store_version', ?)").run(String(STORE_VERSION));
|
|
1157
1189
|
ensureSearchTable(db);
|
|
1158
1190
|
}
|
|
1159
1191
|
|
|
1192
|
+
function backfillArtifactPublishers(db) {
|
|
1193
|
+
const rows = db.prepare(`
|
|
1194
|
+
SELECT
|
|
1195
|
+
id,
|
|
1196
|
+
publisher_id,
|
|
1197
|
+
(
|
|
1198
|
+
SELECT actor
|
|
1199
|
+
FROM audit_log
|
|
1200
|
+
WHERE audit_log.artifact_id = artifacts.id
|
|
1201
|
+
AND action IN ('create', 'import')
|
|
1202
|
+
AND actor IS NOT NULL
|
|
1203
|
+
AND TRIM(actor) != ''
|
|
1204
|
+
ORDER BY created_at ASC
|
|
1205
|
+
LIMIT 1
|
|
1206
|
+
) AS actor
|
|
1207
|
+
FROM artifacts
|
|
1208
|
+
WHERE publisher_id IS NULL
|
|
1209
|
+
OR TRIM(publisher_id) = ''
|
|
1210
|
+
OR publisher_id LIKE 'curl/%'
|
|
1211
|
+
OR publisher_id LIKE 'Wget/%'
|
|
1212
|
+
OR publisher_id LIKE 'HTTPie/%'
|
|
1213
|
+
OR publisher_id LIKE 'PostmanRuntime/%'
|
|
1214
|
+
OR publisher_id LIKE 'undici%'
|
|
1215
|
+
OR publisher_id LIKE 'node-fetch%'
|
|
1216
|
+
OR publisher_id LIKE 'Mozilla/%'
|
|
1217
|
+
`).all();
|
|
1218
|
+
|
|
1219
|
+
if (rows.length === 0) {
|
|
1220
|
+
enrichPublisherUsers(db);
|
|
1221
|
+
return;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
const update = db.prepare(`
|
|
1225
|
+
UPDATE artifacts
|
|
1226
|
+
SET publisher_id = ?, publisher_name = ?, publisher_user_id = ?
|
|
1227
|
+
WHERE id = ?
|
|
1228
|
+
`);
|
|
1229
|
+
|
|
1230
|
+
transaction(db, () => {
|
|
1231
|
+
for (const row of rows) {
|
|
1232
|
+
const existingPublisherId = normalizeOptionalString(row.publisher_id);
|
|
1233
|
+
if (existingPublisherId && !isLikelyUserAgentActor(existingPublisherId)) {
|
|
1234
|
+
continue;
|
|
1235
|
+
}
|
|
1236
|
+
const actor = normalizeOptionalString(row.actor);
|
|
1237
|
+
const publisher = publisherForActor(db, actor);
|
|
1238
|
+
if (!publisher.publisherId && !existingPublisherId) {
|
|
1239
|
+
continue;
|
|
1240
|
+
}
|
|
1241
|
+
update.run(publisher.publisherId, publisher.publisherName, publisher.publisherUserId, row.id);
|
|
1242
|
+
}
|
|
1243
|
+
enrichPublisherUsers(db);
|
|
1244
|
+
});
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
function enrichPublisherUsers(db) {
|
|
1248
|
+
db.prepare(`
|
|
1249
|
+
UPDATE artifacts
|
|
1250
|
+
SET
|
|
1251
|
+
publisher_user_id = COALESCE(
|
|
1252
|
+
publisher_user_id,
|
|
1253
|
+
(SELECT id FROM users WHERE LOWER(users.email) = LOWER(artifacts.publisher_id) LIMIT 1)
|
|
1254
|
+
),
|
|
1255
|
+
publisher_name = COALESCE(
|
|
1256
|
+
NULLIF(publisher_name, ''),
|
|
1257
|
+
(SELECT name FROM users WHERE LOWER(users.email) = LOWER(artifacts.publisher_id) LIMIT 1)
|
|
1258
|
+
)
|
|
1259
|
+
WHERE publisher_id IS NOT NULL AND TRIM(publisher_id) != ''
|
|
1260
|
+
`).run();
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
function publisherForActor(db, actor) {
|
|
1264
|
+
const publisherId = normalizeOptionalString(actor);
|
|
1265
|
+
if (!publisherId || isLikelyUserAgentActor(publisherId)) {
|
|
1266
|
+
return {
|
|
1267
|
+
publisherId: null,
|
|
1268
|
+
publisherName: null,
|
|
1269
|
+
publisherUserId: null
|
|
1270
|
+
};
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
const user = db.prepare("SELECT id, email, name FROM users WHERE LOWER(email) = LOWER(?)").get(publisherId);
|
|
1274
|
+
return {
|
|
1275
|
+
publisherId: user?.email || publisherId,
|
|
1276
|
+
publisherName: user?.name || null,
|
|
1277
|
+
publisherUserId: user?.id || null
|
|
1278
|
+
};
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
function isLikelyUserAgentActor(value) {
|
|
1282
|
+
if (/^(curl|Wget|HTTPie|PostmanRuntime)\//i.test(value)) {
|
|
1283
|
+
return true;
|
|
1284
|
+
}
|
|
1285
|
+
if (/^(undici|node-fetch)(\/|$)/i.test(value)) {
|
|
1286
|
+
return true;
|
|
1287
|
+
}
|
|
1288
|
+
if (value.length < 24) {
|
|
1289
|
+
return false;
|
|
1290
|
+
}
|
|
1291
|
+
return /\b(Mozilla|AppleWebKit|Chrome|Safari|Firefox|Edg)\b/i.test(value);
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1160
1294
|
function migrateJsonIndex(db, store) {
|
|
1161
1295
|
if (!existsSync(store.indexPath)) {
|
|
1162
1296
|
return;
|
|
@@ -1192,11 +1326,17 @@ function migrateJsonIndex(db, store) {
|
|
|
1192
1326
|
|
|
1193
1327
|
function ensureSearchTable(db) {
|
|
1194
1328
|
try {
|
|
1329
|
+
const existing = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'artifact_search'").get();
|
|
1330
|
+
if (existing && !searchTableHasPublisherColumns(db)) {
|
|
1331
|
+
db.exec("DROP TABLE artifact_search");
|
|
1332
|
+
}
|
|
1195
1333
|
db.exec(`
|
|
1196
1334
|
CREATE VIRTUAL TABLE IF NOT EXISTS artifact_search USING fts5(
|
|
1197
1335
|
artifact_id UNINDEXED,
|
|
1198
1336
|
title,
|
|
1199
1337
|
source_agent,
|
|
1338
|
+
publisher_id,
|
|
1339
|
+
publisher_name,
|
|
1200
1340
|
artifact_type,
|
|
1201
1341
|
tags,
|
|
1202
1342
|
format,
|
|
@@ -1212,9 +1352,14 @@ function ensureSearchTable(db) {
|
|
|
1212
1352
|
}
|
|
1213
1353
|
}
|
|
1214
1354
|
|
|
1355
|
+
function searchTableHasPublisherColumns(db) {
|
|
1356
|
+
const columns = db.prepare("PRAGMA table_info(artifact_search)").all().map((row) => row.name);
|
|
1357
|
+
return columns.includes("publisher_id") && columns.includes("publisher_name");
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1215
1360
|
function searchIndexAvailable(db) {
|
|
1216
1361
|
const row = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'artifact_search'").get();
|
|
1217
|
-
return Boolean(row) || ensureSearchTable(db);
|
|
1362
|
+
return (Boolean(row) && searchTableHasPublisherColumns(db)) || ensureSearchTable(db);
|
|
1218
1363
|
}
|
|
1219
1364
|
|
|
1220
1365
|
function syncSearchIndexIfEmpty(db, store) {
|
|
@@ -1280,17 +1425,23 @@ function upsertSearchIndex(db, artifact, version, content) {
|
|
|
1280
1425
|
db.prepare("DELETE FROM artifact_search WHERE artifact_id = ?").run(artifact.id);
|
|
1281
1426
|
db.prepare(`
|
|
1282
1427
|
INSERT INTO artifact_search (
|
|
1283
|
-
artifact_id, title, source_agent, artifact_type, tags, format, metadata, content
|
|
1428
|
+
artifact_id, title, source_agent, publisher_id, publisher_name, artifact_type, tags, format, metadata, content
|
|
1284
1429
|
)
|
|
1285
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
1430
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1286
1431
|
`).run(
|
|
1287
1432
|
artifact.id,
|
|
1288
1433
|
artifact.title,
|
|
1289
1434
|
artifact.sourceAgent,
|
|
1435
|
+
artifact.publisherId || "",
|
|
1436
|
+
artifact.publisherName || "",
|
|
1290
1437
|
artifact.artifactType,
|
|
1291
1438
|
artifact.tags.join(" "),
|
|
1292
1439
|
version.format,
|
|
1293
|
-
metadataSearchText(
|
|
1440
|
+
metadataSearchText({
|
|
1441
|
+
...(version.metadata || {}),
|
|
1442
|
+
publisherId: artifact.publisherId || undefined,
|
|
1443
|
+
publisherName: artifact.publisherName || undefined
|
|
1444
|
+
}),
|
|
1294
1445
|
content
|
|
1295
1446
|
);
|
|
1296
1447
|
}
|
|
@@ -1321,7 +1472,7 @@ function listStoreFiles(root) {
|
|
|
1321
1472
|
|
|
1322
1473
|
function loadArtifacts(db) {
|
|
1323
1474
|
const rows = db.prepare(`
|
|
1324
|
-
SELECT id, title, artifact_type, schema_version, source_agent, tags_json, created_at, updated_at, latest_version, archived_at
|
|
1475
|
+
SELECT id, title, artifact_type, schema_version, source_agent, publisher_id, publisher_name, publisher_user_id, tags_json, created_at, updated_at, latest_version, archived_at
|
|
1325
1476
|
FROM artifacts
|
|
1326
1477
|
ORDER BY updated_at DESC, created_at DESC
|
|
1327
1478
|
`).all();
|
|
@@ -1332,6 +1483,9 @@ function loadArtifacts(db) {
|
|
|
1332
1483
|
artifactType: row.artifact_type,
|
|
1333
1484
|
schemaVersion: row.schema_version,
|
|
1334
1485
|
sourceAgent: row.source_agent,
|
|
1486
|
+
publisherId: row.publisher_id || null,
|
|
1487
|
+
publisherName: row.publisher_name || null,
|
|
1488
|
+
publisherUserId: row.publisher_user_id || null,
|
|
1335
1489
|
tags: parseJson(row.tags_json, []),
|
|
1336
1490
|
createdAt: row.created_at,
|
|
1337
1491
|
updatedAt: row.updated_at,
|
|
@@ -1352,7 +1506,7 @@ function loadVersions(db, artifactId) {
|
|
|
1352
1506
|
|
|
1353
1507
|
function findArtifactById(db, id) {
|
|
1354
1508
|
const row = db.prepare(`
|
|
1355
|
-
SELECT id, title, artifact_type, schema_version, source_agent, tags_json, created_at, updated_at, latest_version, archived_at
|
|
1509
|
+
SELECT id, title, artifact_type, schema_version, source_agent, publisher_id, publisher_name, publisher_user_id, tags_json, created_at, updated_at, latest_version, archived_at
|
|
1356
1510
|
FROM artifacts
|
|
1357
1511
|
WHERE id = ?
|
|
1358
1512
|
`).get(id);
|
|
@@ -1370,6 +1524,9 @@ function findArtifactById(db, id) {
|
|
|
1370
1524
|
artifactType: row.artifact_type,
|
|
1371
1525
|
schemaVersion: row.schema_version,
|
|
1372
1526
|
sourceAgent: row.source_agent,
|
|
1527
|
+
publisherId: row.publisher_id || null,
|
|
1528
|
+
publisherName: row.publisher_name || null,
|
|
1529
|
+
publisherUserId: row.publisher_user_id || null,
|
|
1373
1530
|
tags: parseJson(row.tags_json, []),
|
|
1374
1531
|
createdAt: row.created_at,
|
|
1375
1532
|
updatedAt: row.updated_at,
|
|
@@ -1382,15 +1539,18 @@ function findArtifactById(db, id) {
|
|
|
1382
1539
|
function insertArtifactRecord(db, artifact) {
|
|
1383
1540
|
db.prepare(`
|
|
1384
1541
|
INSERT INTO artifacts (
|
|
1385
|
-
id, title, artifact_type, schema_version, source_agent, tags_json, created_at, updated_at, latest_version, archived_at
|
|
1542
|
+
id, title, artifact_type, schema_version, source_agent, publisher_id, publisher_name, publisher_user_id, tags_json, created_at, updated_at, latest_version, archived_at
|
|
1386
1543
|
)
|
|
1387
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1544
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1388
1545
|
`).run(
|
|
1389
1546
|
artifact.id,
|
|
1390
1547
|
artifact.title,
|
|
1391
1548
|
normalizeArtifactType(artifact.artifactType || artifact.artifact_type || "document"),
|
|
1392
1549
|
normalizeSchemaVersion(artifact.schemaVersion || artifact.schema_version),
|
|
1393
1550
|
artifact.sourceAgent || artifact.source_agent || "unknown",
|
|
1551
|
+
normalizeOptionalString(artifact.publisherId || artifact.publisher_id) || null,
|
|
1552
|
+
normalizeOptionalString(artifact.publisherName || artifact.publisher_name) || null,
|
|
1553
|
+
normalizeOptionalString(artifact.publisherUserId || artifact.publisher_user_id) || null,
|
|
1394
1554
|
JSON.stringify(artifact.tags || []),
|
|
1395
1555
|
artifact.createdAt || artifact.created_at,
|
|
1396
1556
|
artifact.updatedAt || artifact.updated_at,
|
package/src/server.js
CHANGED
|
@@ -967,8 +967,9 @@ function auditContext(request, surface) {
|
|
|
967
967
|
const auth = request.artifactyAuth;
|
|
968
968
|
return {
|
|
969
969
|
surface,
|
|
970
|
-
actor: auth?.actor || request.headers["x-artifacty-actor"] ||
|
|
970
|
+
actor: auth?.actor || request.headers["x-artifacty-actor"] || "unknown",
|
|
971
971
|
userId: auth?.user?.id || null,
|
|
972
|
+
publisherName: auth?.user?.name || null,
|
|
972
973
|
tokenId: auth?.tokenId || null
|
|
973
974
|
};
|
|
974
975
|
}
|
|
@@ -992,7 +993,7 @@ async function requireRequestAuth({ store, request, url, body = {}, config }) {
|
|
|
992
993
|
if (!authRequired) {
|
|
993
994
|
return {
|
|
994
995
|
type: "anonymous",
|
|
995
|
-
actor: request.headers["x-artifacty-actor"] ||
|
|
996
|
+
actor: request.headers["x-artifacty-actor"] || "anonymous"
|
|
996
997
|
};
|
|
997
998
|
}
|
|
998
999
|
|