artifacty 0.10.2 → 0.10.4

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 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, and audit logs record the token owner's email as `actor`.
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 audit `actor` when a
166
- personal token is used. The `x-artifacty-actor` header remains a compatibility
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
 
@@ -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 and are mapped to the token owner's email in audit logs. Browser forms can also carry `?token=<token>` in the URL, which is copied to hidden form fields for local team workflows.
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
 
@@ -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 audit logs record
70
- the token owner's email as the artifact actor.
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`
@@ -44,9 +44,9 @@ When Artifacty binds outside loopback, startup output includes a warning that th
44
44
 
45
45
  ## Browser Write Behavior
46
46
 
47
- Remote browsers can read shared pages, but write actions are intentionally conservative. Mutating browser routes reject non-local `Origin` headers to reduce CSRF risk. For LAN sharing, prefer API or MCP writes with an explicit token header.
47
+ Remote browsers can read shared pages and can submit browser forms when the request is same-origin with the Artifacty host, for example `Origin: http://10.0.0.50:8787` with `Host: 10.0.0.50:8787`. Mutating browser routes reject cross-origin requests to reduce CSRF risk. For scripts and agents, prefer API or MCP writes with an explicit token header.
48
48
 
49
- Do not relax the origin check just to make remote browser writes easier. A future team dashboard should use a dedicated policy that combines same-origin remote requests, explicit token validation, and clear operator intent.
49
+ Do not disable the origin check just to make remote browser writes easier. Same-origin central dashboard writes should pass; writes initiated from another website should fail with `NON_LOCAL_ORIGIN`.
50
50
 
51
51
  ## Renderer Guidance
52
52
 
@@ -68,9 +68,10 @@ reachable from the user's browser.
68
68
 
69
69
  Controls:
70
70
 
71
- - Mutating browser routes reject non-local `Origin` headers.
71
+ - Mutating browser routes allow loopback or same-origin requests and reject
72
+ cross-origin writes.
72
73
  - API routes require a token when configured.
73
- - LAN/team mode does not relax browser-origin checks.
74
+ - LAN/team mode does not allow arbitrary browser origins.
74
75
 
75
76
  ### Untrusted Artifact Rendering
76
77
 
@@ -114,8 +115,8 @@ Controls:
114
115
  - The HTTP MCP endpoint is disabled unless explicitly enabled.
115
116
  - Remote MCP requests require the configured API token.
116
117
  - Stdio bridge mode sends tokens in headers, not URLs.
117
- - Personal tokens map requests to a server-side user record for audit actor
118
- attribution.
118
+ - Personal tokens map requests to a server-side user record for artifact
119
+ `publisherId` and audit `actor` attribution.
119
120
  - MCP writes go through the same secret scan and audit paths as CLI/HTTP writes.
120
121
  - MCP resources are read-only.
121
122
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "artifacty",
3
- "version": "0.10.2",
3
+ "version": "0.10.4",
4
4
  "description": "Local artifact exchange for heterogeneous LLM agents via HTTP and MCP.",
5
5
  "type": "module",
6
6
  "keywords": [
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) 120px 120px 90px 64px minmax(120px, 220px);
1299
- gap: 16px;
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
  }
@@ -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 = 3;
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, 7, '', '', '...', 24) AS search_snippet
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(version.metadata),
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
@@ -816,11 +816,25 @@ export function assertLocalOrigin(request) {
816
816
  return;
817
817
  }
818
818
 
819
- const parsed = new URL(origin);
819
+ let parsed;
820
+ try {
821
+ parsed = new URL(origin);
822
+ } catch {
823
+ throw Object.assign(new Error(`Rejected invalid origin: ${origin}`), {
824
+ statusCode: 403,
825
+ code: "NON_LOCAL_ORIGIN"
826
+ });
827
+ }
828
+
820
829
  const hostname = parsed.hostname.toLowerCase();
821
- const allowed = hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
830
+ const host = String(request.headers.host || "").toLowerCase();
831
+ const allowed =
832
+ hostname === "localhost" ||
833
+ hostname === "127.0.0.1" ||
834
+ hostname === "::1" ||
835
+ (host && parsed.host.toLowerCase() === host && (parsed.protocol === "http:" || parsed.protocol === "https:"));
822
836
  if (!allowed) {
823
- throw Object.assign(new Error(`Rejected non-local origin: ${origin}`), {
837
+ throw Object.assign(new Error(`Rejected untrusted origin: ${origin}`), {
824
838
  statusCode: 403,
825
839
  code: "NON_LOCAL_ORIGIN"
826
840
  });
@@ -967,8 +981,9 @@ function auditContext(request, surface) {
967
981
  const auth = request.artifactyAuth;
968
982
  return {
969
983
  surface,
970
- actor: auth?.actor || request.headers["x-artifacty-actor"] || request.headers["user-agent"] || "unknown",
984
+ actor: auth?.actor || request.headers["x-artifacty-actor"] || "unknown",
971
985
  userId: auth?.user?.id || null,
986
+ publisherName: auth?.user?.name || null,
972
987
  tokenId: auth?.tokenId || null
973
988
  };
974
989
  }
@@ -992,7 +1007,7 @@ async function requireRequestAuth({ store, request, url, body = {}, config }) {
992
1007
  if (!authRequired) {
993
1008
  return {
994
1009
  type: "anonymous",
995
- actor: request.headers["x-artifacty-actor"] || request.headers["user-agent"] || "anonymous"
1010
+ actor: request.headers["x-artifacty-actor"] || "anonymous"
996
1011
  };
997
1012
  }
998
1013