artifacty 0.10.9 → 0.11.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/CLAUDE.md CHANGED
@@ -39,7 +39,7 @@ The store lives at `ARTIFACTY_HOME` or `~/.artifacty` by default.
39
39
  - `server.json` records the currently running browser server URL so CLI and MCP responses keep working when the default port falls back.
40
40
  - Legacy `index.json` stores migrate automatically on first access.
41
41
 
42
- Versions are append-only. Do not mutate prior content files.
42
+ Versions are append-only. Do not mutate prior content files, except through the audited admin repair (`replaceArtifactVersion`) and delete (`deleteArtifactVersion`) paths, which exist specifically to correct bad historical versions and record every change in the audit log.
43
43
 
44
44
  ### Security Model
45
45
 
package/README.md CHANGED
@@ -236,8 +236,11 @@ artifacty doctor
236
236
  artifacty index rebuild
237
237
  artifacty integrity
238
238
  artifacty backup
239
+ artifacty backup --full
239
240
  artifacty export --file ./artifacty-backup.json
241
+ artifacty export --file ./artifacty-backup-full.json --full
240
242
  artifacty import-store --file ./artifacty-backup.json
243
+ artifacty import-store --file ./artifacty-backup-full.json --confirm replace-all [--force-users]
241
244
  artifacty start
242
245
  artifacty status
243
246
  artifacty stop
@@ -258,12 +261,30 @@ ARTIFACTY_HOME=/path/to/shared/store artifacty serve
258
261
 
259
262
  Artifact metadata is stored in `artifacty.sqlite`; artifact content is stored as append-only version files under `artifacts/` for normal create and update flows. Administrators can repair or delete individual bad versions from the browser, and those exceptional actions are recorded in the audit log. The current browser server URL is written to `server.json` so MCP tools can return the correct links when the default port falls back. Existing `index.json` stores are migrated automatically on first access.
260
263
 
261
- Administrators can download and restore artifact backups from `/admin/backup`.
262
- The backup bundle contains artifact metadata and version contents, but not
263
- users, sessions, API token records, or audit logs. Restoring a bundle replaces
264
- the target server's artifact records and prunes unreferenced version files. For
265
- large migrations or scripted server moves, use `artifacty backup` and
266
- `artifacty import-store --file ./artifacty-backup.json`.
264
+ Every artifact row carries a `visibility` (`private`/`team`, default `team`) and an `ownerUserId`, backfilled from the artifact's `publisherUserId` on migration. See the Security Model section below for the access rules these enforce.
265
+
266
+ Administrators can download and restore backups from `/admin/backup`. The
267
+ default `artifacts` scope contains artifact metadata and version contents,
268
+ but not users, sessions, API token records, or audit logs. Restoring an
269
+ artifacts-scope bundle replaces the target server's artifact records and
270
+ prunes unreferenced version files, leaving users, tokens, and audit history
271
+ untouched.
272
+
273
+ A `full`-scope backup (`artifacty backup --full`, `?scope=full` on the
274
+ download, or the scope selector on `/admin/backup`) additionally includes
275
+ users (with password hashes), API tokens (hashed, never the raw token),
276
+ audit log entries, artifact relations, and webhooks. Webhook secrets are
277
+ never exported — restored webhooks come back disabled and must have their
278
+ secret re-issued. Sessions are never exported. Restoring a `full` bundle
279
+ requires explicit confirmation (`confirm: "replace-all"` over the API,
280
+ `--confirm replace-all` on the CLI, or the confirm checkbox in the browser)
281
+ and refuses to run when the target store already has users unless
282
+ `forceUsers`/`--force-users` is also set, since that would silently merge or
283
+ overwrite existing accounts. Restoring a bundle written before this feature
284
+ (no `bundleVersion`/`scope` header) is still supported and is always treated
285
+ as artifacts-only. For large migrations or scripted server moves, use
286
+ `artifacty backup --full` and
287
+ `artifacty import-store --file ./artifacty-backup.json --confirm replace-all`.
267
288
 
268
289
  Search uses a SQLite FTS5 index when the local Node SQLite build supports it. The index covers the latest version body plus title, tags, source agent, artifact type, format, and metadata summary. If FTS5 is unavailable, Artifacty keeps working with metadata search. Rebuild or check the store when needed:
269
290
 
@@ -272,6 +293,156 @@ artifacty index rebuild
272
293
  artifacty integrity
273
294
  ```
274
295
 
296
+ ### Semantic Search
297
+
298
+ `?mode=keyword|semantic|hybrid` on `GET /api/artifacts`, the dashboard, `artifacty list --mode`, and the MCP `artifacty_list` tool add optional natural-language search on top of FTS5, without any bundled model or mandatory dependency:
299
+
300
+ - `keyword` is the existing FTS5/metadata path.
301
+ - `semantic` embeds the query and ranks artifacts by cosine similarity over stored vectors.
302
+ - `hybrid` (the default once a provider is configured and `q`/`query` is set) merges keyword and semantic rankings with reciprocal rank fusion.
303
+
304
+ Without an embedding provider configured, `semantic`/`hybrid` requests fall back to `keyword` and the response's `search.fallback` is `true`. Configure one provider through the environment:
305
+
306
+ ```bash
307
+ # openai-compatible: POSTs { model, input } to <url>/embeddings
308
+ export ARTIFACTY_EMBEDDINGS_URL=https://api.openai.com/v1
309
+ export ARTIFACTY_EMBEDDINGS_MODEL=text-embedding-3-small
310
+ export ARTIFACTY_EMBEDDINGS_API_KEY=sk-...
311
+
312
+ # or: command, a local executable reading/writing JSON lines on stdin/stdout
313
+ # (wire in Ollama or any local model without an Artifacty dependency)
314
+ export ARTIFACTY_EMBEDDINGS_COMMAND="python3 embed.py"
315
+ ```
316
+
317
+ The API key is read from the environment only; it is never logged, returned in an error, or persisted to the store (`artifacty doctor` reports the provider and model with the key redacted). New artifacts are embedded automatically in the background after create/update, and existing ones can be (re-)embedded in batches of 16 with:
318
+
319
+ ```bash
320
+ artifacty index rebuild --embeddings
321
+ ```
322
+
323
+ ### Relations
324
+
325
+ Artifacts can be linked with typed, directional relations so a handoff, its review, and follow-up work stay discoverable from one another:
326
+
327
+ - Relation names are a closed set for v1: `derived-from`, `supersedes`, `reviews`, `references`, `part-of`.
328
+ - Every relation has a computed inverse (`derived-from` ↔ `derives`, `supersedes` ↔ `superseded-by`, `reviews` ↔ `reviewed-by`, `references` ↔ `referenced-by`, `part-of` ↔ `contains`) shown automatically on the other side.
329
+ - `POST`/`GET`/`DELETE /api/artifacts/:id/relations` manage links over HTTP; `GET /api/artifacts` accepts `relatedTo` and `relation` filters.
330
+ - `artifacty_create` and `artifacty_update` accept a `relations` array so an agent can link an artifact the moment it is published; `artifacty_get` responses include `relations: { outgoing, incoming }`.
331
+ - A relation whose target was deleted is reported with `missing: true` instead of being silently dropped.
332
+
333
+ ```bash
334
+ artifacty link <from-id> derived-from <to-id>
335
+ artifacty relations <artifact-id>
336
+ artifacty show <artifact-id> --relations
337
+ artifacty unlink <from-id> derived-from <to-id>
338
+ ```
339
+
340
+ ### Comments and review status
341
+
342
+ Lightweight, version-anchored feedback without publishing a whole new version or a separate review artifact:
343
+
344
+ - Comments are Markdown, rendered through the same sanitized Markdown pipeline as artifact content, and capped at 16 KB. `anchor` is an optional, format-specific rendering hint — `{ line: 42 }` for text formats, `{ path: "$.runs[0].results[3]" }` for JSON/SARIF, `{ row: 7 }` for CSV — and is not validated against the artifact's actual content.
345
+ - Threads are one level deep: `parentId` on a new comment must point to a root comment (one with no `parentId` of its own).
346
+ - Deleting a comment is a soft delete: it disappears from `artifacty comments`/`GET .../comments` but its audit-log entry is kept. Pass `--include-deleted`/`includeDeleted=true` to see it anyway.
347
+ - Every artifact carries a `reviewStatus`: `none`, `pending`, `changes-requested`, or `approved`, set explicitly and reset to `pending` automatically (noted in that update's audit metadata) whenever a new version is appended after an `approved` status.
348
+ - `GET`/`POST /api/artifacts/:id/comments`, `POST /api/artifacts/:id/comments/:commentId/resolve`, `DELETE /api/artifacts/:id/comments/:commentId`, and `POST /api/artifacts/:id/review-status` manage comments and review status over HTTP.
349
+ - `artifacty_comment`, `artifacty_resolve_comment`, and `artifacty_set_review_status` are the MCP equivalents; `artifacty_get` accepts `includeComments: true` to return the requested version's open comments alongside the artifact.
350
+ - The browser artifact page shows a comments panel grouped by version, with a reply form, a resolve button, and (for the artifact's owner or an admin) a review-status selector. Line-anchored comments are shown as plain text (e.g. "line 42") rather than a CodeMirror gutter marker.
351
+
352
+ ```bash
353
+ artifacty comment release-handoff-abc12345 --body "Looks good, one nit" --line 42
354
+ artifacty comments release-handoff-abc12345
355
+ artifacty resolve-comment release-handoff-abc12345 <comment-id>
356
+ artifacty review-status release-handoff-abc12345 approved
357
+ ```
358
+
359
+ ### Diff
360
+
361
+ `artifacty diff <id> [--from N] [--to M] [--structured] [--json]` compares two versions of an artifact, defaulting `--to` to the latest version and `--from` to `--to - 1`. JSON, SARIF, CSV, notebook, and bundle artifacts get a structured diff (JSON path entries, CSV row/cell entries, or per-file bundle entries) by default; other formats print a line diff. `--structured` forces the structured (word-highlighted line) diff for any format; `--json` prints the machine-readable form instead of the human-readable unified text.
362
+
363
+ ```bash
364
+ artifacty diff release-handoff-abc12345
365
+ artifacty diff release-handoff-abc12345 --from 1 --to 3 --json
366
+ ```
367
+
368
+ ### Retention
369
+
370
+ Artifacts, audit rows, and events grow without bound by default. A declarative retention policy, stored in the `meta` table and edited through `/admin/retention` or `artifacty retention set`, lets an admin bound that growth:
371
+
372
+ - `archiveAfterDays` (a default plus optional per-artifact-type overrides) auto-archives artifacts that have not been updated within the window, unless the artifact carries a tag in `keepTags` or has `reviewStatus: "approved"`.
373
+ - `purgeArchivedAfterDays` hard-deletes artifacts that have been archived longer than the window, including their version files, dependent rows that cascade via foreign keys (comments, relations, embeddings), and their `events`/search-index rows. Audit log rows for the artifact are deliberately kept (see `auditRetentionDays` below) as the historical record that the purge happened, even though the artifact itself is gone. This is the only retention action that deletes data outright, so it is refused unless the operator has set `ARTIFACTY_RETENTION_ALLOW_PURGE=true` in the server environment; `--allow-purge` (or the `allowPurge` request field) only opts a single run in and never sets that environment variable itself. Always dry-run first (`artifacty retention run --dry-run`, or the report on `/admin/retention`) to review what would be purged.
374
+ - `auditRetentionDays` prunes old audit log rows, except `version-repair`, `version-delete`, `retention-purge`, `retention-policy-update`, and `owner-change`, which are kept indefinitely.
375
+ - `eventRetentionRows` caps how many rows the `events` change-notification table keeps.
376
+
377
+ A background sweep runs every `ARTIFACTY_RETENTION_INTERVAL_MS` (default one hour) alongside the HTTP server, checking the current policy on every tick — it starts even when the policy is all-unset, so setting a policy later takes effect on the next tick without a restart. `artifacty integrity`/`checkStoreIntegrity` flags any files left behind if a purge is ever interrupted, and `artifacty doctor` reports the current retention policy summary.
378
+
379
+ ```bash
380
+ artifacty retention show
381
+ artifacty retention set --archive-after-days 90 --archive-after-days-for test-report=30 --purge-archived-after-days 180 --audit-retention-days 365 --event-retention-rows 10000 --keep-tag pinned
382
+ artifacty retention run --dry-run
383
+ ARTIFACTY_RETENTION_ALLOW_PURGE=true artifacty retention run --allow-purge
384
+ ```
385
+
386
+ ### Dashboard Filters and Saved Views
387
+
388
+ `listArtifactsPage` (and every surface built on it — the `/` dashboard, `GET /api/artifacts`, `artifacty list`, and `artifacty_list`) accepts these filters in addition to `q`/`tag`/`sourceAgent`/`relatedTo`/`relation`/`includeArchived`:
389
+
390
+ - `artifactType` — exact match against the artifact's type.
391
+ - `publisher` — matches `publisherId`, `publisherUserId`, or `ownerUserId`.
392
+ - `createdAfter` / `createdBefore` — ISO date or date-time bounds on `createdAt`. An unparseable value returns a `400` with `code: "invalid_filter"`.
393
+ - `reviewStatus` — the artifact-level review status: `none`, `pending`, `changes-requested`, or `approved`.
394
+
395
+ A frequently used filter combination can be saved as a **view**:
396
+
397
+ - `POST /api/views` with `{ name, filters, shared }` creates one; `GET /api/views` lists views visible to the caller; `DELETE /api/views/:id` removes one. `filters` is validated against an allowlist (`query`, `tag`, `sourceAgent`, `artifactType`, `publisher`, `createdAfter`, `createdBefore`, `reviewStatus`, `relatedTo`, `relation`, `includeArchived`, `mode`) and rejects any other key with `code: "invalid_filter"`.
398
+ - In single-user mode (no user accounts) views are global. In team mode a view belongs to its creator and is visible only to them unless `shared: true`, in which case every user sees it.
399
+ - `?view=<name-or-id>` on `/` or `GET /api/artifacts` expands the saved filters first; any other query parameter passed alongside `view` overrides that one filter. `artifacty_list` and `artifacty list --view <name-or-id>` work the same way.
400
+ - The dashboard sidebar lists views with delete buttons and a "Save current filters" form. `artifacty views`, `artifacty views save <name> [--shared] [filters...]`, and `artifacty views delete <id>` manage them from the CLI.
401
+ - `groupBy=artifactType|sourceAgent|day` (dashboard query param, `--group-by` on the CLI) renders the already-fetched page grouped under section headers. It is purely presentational and does not change which artifacts are fetched or their sort order.
402
+
403
+ ```bash
404
+ artifacty list --type handoff --review-status pending --created-after 2025-01-01
405
+ artifacty views save open-handoffs --type handoff --review-status pending --shared
406
+ artifacty list --view open-handoffs --group-by sourceAgent
407
+ artifacty views delete <view-id>
408
+ ```
409
+
410
+ ## Events
411
+
412
+ Every mutation (`artifact.created`, `artifact.updated`, `artifact.archived`, `artifact.restored`, `artifact.relation.added`, `artifact.version.repaired`, `artifact.version.deleted`) publishes an event agents can react to instead of polling `artifacty_list`.
413
+
414
+ **Watch from a shell:**
415
+
416
+ ```bash
417
+ artifacty watch --tag handoff --type artifact.updated --json
418
+ artifacty watch --artifact release-handoff-abc12345 --exec "./on-change.sh"
419
+ artifacty watch --once # exits 0 after the first match
420
+ ```
421
+
422
+ `--exec` runs the command with the event JSON on stdin and `ARTIFACTY_EVENT_TYPE`, `ARTIFACTY_EVENT_ARTIFACT_ID`, `ARTIFACTY_EVENT_VERSION` environment variables. `watch` reconnects automatically with `Last-Event-ID` if the connection drops.
423
+
424
+ **Server-Sent Events:**
425
+
426
+ ```bash
427
+ curl -N -H "x-artifacty-token: $ARTIFACTY_API_TOKEN" \
428
+ "http://127.0.0.1:8787/api/events?tag=handoff"
429
+ ```
430
+
431
+ Filters: `type`, `tag`, `artifactId`, `sourceAgent`. Send `Last-Event-ID` (a request header, matching the `id:` field of the last event you saw) to replay missed events after a reconnect. Without `Accept: text/event-stream`, `GET /api/events?since=<seq>` returns one JSON page of events instead (useful for polling or tests). The server sends a heartbeat comment every 25 seconds and caps concurrent streams at `ARTIFACTY_SSE_MAX_CLIENTS` (default 64; excess connections get `503`).
432
+
433
+ **Webhooks** deliver the same events to an external HTTP endpoint:
434
+
435
+ ```bash
436
+ curl -s -X POST http://127.0.0.1:8787/api/webhooks \
437
+ -H 'content-type: application/json' \
438
+ -H "x-artifacty-token: $ARTIFACTY_API_TOKEN" \
439
+ -d '{ "url": "https://example.com/hooks/artifacty", "eventTypes": ["artifact.updated"] }'
440
+ ```
441
+
442
+ The response includes `secret` once — store it; it is never returned again. Deliveries are `POST` JSON with headers `X-Artifacty-Event`, `X-Artifacty-Delivery`, and `X-Artifacty-Signature: sha256=<hmac>`, retried up to 3 times (2s/10s/60s backoff) and disabled after 20 consecutive failures. Manage webhooks at `/admin/webhooks` in the browser, or `GET/POST /api/webhooks`, `DELETE /api/webhooks/:id`, `POST /api/webhooks/:id/test`. See [docs/threat-model.md](docs/threat-model.md) for the signature scheme and SSRF guard.
443
+
444
+ **MCP:** `resources/subscribe` on `artifacty://artifacts/{id}` or `artifacty://recent` delivers `notifications/resources/updated` over the stdio transport. The `artifacty_wait` tool (`{ artifactId?, tag?, type?, timeoutMs }`, max 120000ms) is a long-poll alternative that works over any transport, including the stateless `/mcp` HTTP transport where `resources/subscribe` cannot push notifications. See [docs/mcp-public-api.md](docs/mcp-public-api.md).
445
+
275
446
  ## API Example
276
447
 
277
448
  Start a protected server with a reusable shell token:
@@ -314,12 +485,14 @@ Browser routes:
314
485
  - `/new`: create an Artifacty-native artifact with the CodeMirror editor.
315
486
  - `/import`: paste an external agent artifact and convert it with automatic editor mode detection.
316
487
  - `/artifacts/:id/edit`: save a new version with Markdown, HTML, JSON, text, code, SVG, Mermaid, React, SARIF, CSV, image, or video syntax support. Browser edits that do not change the artifact are recorded as `update-noop` audit events without creating a version.
317
- - `/artifacts/:id/diff`: compare versions.
488
+ - `/artifacts/:id/diff?from=&to=&view=structured|lines`: compare versions. `structured` is the default view for JSON-like formats (`json`, `sarif`, `csv`, `notebook`, bundle artifacts) and shows JSON path, CSV row/cell, or bundle per-file changes; other formats default to the line view with word-level highlighting on changed lines.
489
+ - `/api/artifacts/:id/diff?from=&to=&view=`: the same diff as structured JSON.
318
490
  - `/admin/artifacts/:id/versions`: administrator-only repair/delete screen for individual versions.
319
- - `/admin/backup`: administrator-only artifact backup download and restore screen.
320
- - `/api/admin/backup`: administrator-only artifact backup JSON download.
321
- - `/api/admin/backup/import`: administrator-only artifact backup restore.
491
+ - `/admin/backup`: administrator-only backup download and restore screen, with an `artifacts`/`full` scope selector.
492
+ - `/api/admin/backup?scope=artifacts|full`: administrator-only backup JSON download.
493
+ - `/api/admin/backup/import`: administrator-only backup restore. Accepts the raw bundle or `{ bundle, confirm, forceUsers }`.
322
494
  - `/api/audit`: list audit events.
495
+ - `/openapi.json`: machine-readable OpenAPI 3.1 document for every `/api/*` route and `/mcp`; `/docs/api`: server-rendered human-readable reference for the same routes. Both require no authentication.
323
496
 
324
497
  List APIs support pagination with `limit` and `offset`. Responses keep the top-level `artifacts` array and include `pagination` and `search` metadata:
325
498
 
@@ -328,6 +501,26 @@ curl -s "http://127.0.0.1:8787/api/artifacts?q=handoff&limit=20&offset=0" \
328
501
  -H "x-artifacty-token: $ARTIFACTY_API_TOKEN"
329
502
  ```
330
503
 
504
+ ### Optimistic Concurrency
505
+
506
+ Every artifact response carries `latestVersion` and an `etag` (`"<id>:<latestVersion>"`). When two agents read the same version and both try to save a new one, the second write can silently discard the first agent's work. To avoid that, pass back the version you read as `expectedVersion` in the update body, or send it as an `If-Match` header (a weak `W/"..."` prefix is accepted):
507
+
508
+ ```bash
509
+ curl -s http://127.0.0.1:8787/api/artifacts/<id> \
510
+ -H 'content-type: application/json' \
511
+ -H "x-artifacty-token: $ARTIFACTY_API_TOKEN" \
512
+ -H 'If-Match: "<id>:3"' \
513
+ -d '{ "content": "updated content" }'
514
+ ```
515
+
516
+ If the artifact's `latestVersion` no longer matches, the update is rejected with `409 Version conflict`:
517
+
518
+ ```json
519
+ { "error": "Version conflict", "code": "version_conflict", "details": { "latestVersion": 4 } }
520
+ ```
521
+
522
+ `GET /api/artifacts/:id` sets an `ETag` header and returns `304 Not Modified` when the request's `If-None-Match` matches the current version. `expectedVersion` is optional, so clients that don't send it keep working exactly as before. The browser editor sends its own hidden `expectedVersion` field and shows a banner with a link to the diff view if another agent updated the artifact first.
523
+
331
524
  ## Interface Language
332
525
 
333
526
  The browser UI defaults to English. Add `?lang=ko` to any browser route to use Korean, for example `http://127.0.0.1:8787/new?lang=ko`. Forms and in-app links preserve the selected language. Documentation is maintained in English only.
@@ -336,8 +529,8 @@ Schema and storage:
336
529
 
337
530
  - Metadata lives in SQLite with `schemaVersion: 1`, `artifactType`, `publisherId`, and `archivedAt`.
338
531
  - Archive hides artifacts from default lists without deleting versions. Admin version repair/delete is available for correcting accidental or sensitive historical versions and records `version-repair` or `version-delete` audit events.
339
- - Bundle artifacts store multiple files or base64 assets as portable JSON.
340
- - Supported formats are `html`, `markdown`, `text`, `json`, `code`, `svg`, `mermaid`, `react`, `sarif`, `csv`, `image`, and `video`.
532
+ - Bundle artifacts store multiple files or base64 assets as portable JSON. A `files` entry can also carry a binary document (PDF, DOCX, XLSX, or ZIP, base64-encoded, 32 MB per file) served at `GET /artifacts/:id/raw?file=<path>`; PDFs render in a sandboxed viewer iframe and other document types show a download link.
533
+ - Supported formats are `html`, `markdown`, `text`, `json`, `code`, `svg`, `mermaid`, `react`, `sarif`, `csv`, `image`, `video`, and `notebook`.
341
534
  - Native create/import paths infer `html` from HTML documents or fragments when no explicit format is supplied.
342
535
  - `sourceAgent` is canonicalized before storage. Aliases such as `claude-code`, `Claude Code`, `github-copilot`, and `gemini-cli` are stored as `claude`, `copilot`, and `gemini`; legacy `unknown` rows are backfilled only when version metadata, audit data, or source-agent tags provide a known agent.
343
536
  - Diagram, component, source snippet, analysis report, table, and media assets use `diagram`, `component`, `snippet`, `analysis-report`, `table`, and `asset` artifact types.
@@ -346,6 +539,7 @@ Schema and storage:
346
539
  - See [docs/mcp-public-api.md](docs/mcp-public-api.md) for MCP tools, resources, prompts, and compatibility notes.
347
540
  - See [docs/central-team-deployment-design.md](docs/central-team-deployment-design.md) for central team deployment.
348
541
  - See [docs/sarif-csv-artifact-plan.md](docs/sarif-csv-artifact-plan.md) for the SARIF/CSV output artifact roadmap.
542
+ - See [docs/roadmap-design.md](docs/roadmap-design.md) for the design of planned features such as relations, change notifications, optimistic concurrency, comments, semantic search, visibility, token scopes, and retention.
349
543
 
350
544
  ## Security Model
351
545
 
@@ -353,6 +547,9 @@ Schema and storage:
353
547
  - If `ARTIFACTY_API_TOKEN` is set, HTTP API routes require `Authorization: Bearer <token>` or `x-artifacty-token`; scripts should prefer headers over `?token=...` URLs.
354
548
  - 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`.
355
549
  - API token checks use timing-safe digest comparison.
550
+ - Personal API tokens carry scopes (`read`, `write`, `admin`; default `read`+`write`), settable as checkboxes when creating a token from `/account`. `admin` is only grantable to an admin user's token. Every `/api/*` route requires the matching scope on the authenticating token; the shared `ARTIFACTY_API_TOKEN` and browser sessions always have full scopes. A denial responds `403` with `code: "scope_denied"` and writes a rate-limited `token-scope-denied` audit row. Over MCP, `tools/list` omits mutating tools for a token without `write`, and calling one anyway returns an error result with `structuredContent.code: "scope_denied"`.
551
+ - Once any user account exists, every artifact has a `visibility` (`private` or `team`, defaulting to `team`) and an `ownerUserId`. `team` is readable and writable by any authenticated principal (unless `ARTIFACTY_TEAM_WRITE=owner` restricts writes to the owner or an admin). `private` is readable and writable only by its owner or an admin; reads by anyone else 404 instead of 403 so the artifact's existence isn't leaked. Archiving, restoring, and changing visibility or ownership always require the owner or an admin, regardless of visibility. A shared `ARTIFACTY_API_TOKEN` (or any request before the first user account is created) has no personal identity and is treated as an anonymous team principal: it can read and write `team` artifacts but never sees `private` ones. In single-user mode (no user accounts at all) every check is bypassed, matching pre-visibility behavior. Change visibility and ownership with `POST /api/artifacts/:id/visibility` and `POST /api/artifacts/:id/owner`, the `artifacty visibility <id> private|team` CLI command, or the `artifacty_set_visibility` MCP tool.
552
+ - HTTP and MCP-over-HTTP requests are rate limited per `(token or user or remote address, bucket)` in fixed one-minute windows: `write` (mutating routes, default 120/min, `ARTIFACTY_RATE_WRITE_PER_MIN`), `auth` (`/login`, default 10/min per address, `ARTIFACTY_RATE_AUTH_PER_MIN`), and `search` (`GET /api/artifacts?q=`, default 300/min, `ARTIFACTY_RATE_SEARCH_PER_MIN`). Rate limiting is disabled on a loopback bind unless `ARTIFACTY_RATE_LIMIT=always`, and always disabled with `ARTIFACTY_RATE_LIMIT=off`. Exceeding a limit returns `429` with a `Retry-After` header, `code: "rate_limited"`, and a rate-limited `rate-limited` audit row.
356
553
  - Binding outside localhost requires both `ARTIFACTY_SHARE_MODE=lan` or `team` and `ARTIFACTY_API_TOKEN`.
357
554
  - 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).
358
555
  - Non-local binding prints a startup warning because Artifacty does not terminate TLS.
@@ -364,10 +561,58 @@ Schema and storage:
364
561
  - SVG artifacts render in a scriptless sandboxed iframe and are sanitized for `<script>`, `on*` attributes, and `javascript:` links in the viewer. The raw source remains unchanged.
365
562
  - Mermaid artifacts render with the vendored local Mermaid package in a sandboxed iframe without `allow-same-origin`.
366
563
  - React artifacts are source-only by default. Set `ARTIFACTY_ENABLE_REACT_RENDERER=true` to execute them in a sandboxed frame with a frame-scoped CSP that permits JSX transformation.
367
- - SARIF artifacts render a bounded findings summary and keep the full formatted JSON behind a raw-source details panel.
368
- - CSV artifacts render as an escaped, bounded table; `/raw` preserves the original text.
564
+ - SARIF artifacts render a bounded findings summary and keep the full formatted JSON behind a raw-source details panel. The viewer adds client-side level filter chips (error/warning/note), a rule id text filter, and sort-by-level/rule/location over the rendered set, all as progressive enhancement (the server output is already correct without JS).
565
+ - CSV artifacts render as an escaped, bounded table; `/raw` preserves the original text. The viewer adds client-side numeric-aware column sort (click a header) and a per-column contains filter, with a visible row count.
566
+ - `GET /artifacts/:id/export?format=csv&sort=<col>&dir=asc|desc&filter=<col>:<text>[,...]` and `GET /artifacts/:id/export?format=sarif&level=error,warning&rule=<text>` re-parse the stored original (never `/raw` itself), apply the filter/sort, and stream a fresh file with `Content-Disposition: attachment`, capped at the max artifact byte size. Invalid parameters return 400 with `code: "invalid_export"`.
369
567
  - Image and video artifacts store base64 media inline, render safe previews, and decode bytes through `/raw`.
568
+ - Markdown artifacts render fenced code blocks with CodeMirror read-only syntax highlighting client-side (the server output stays plain escaped HTML, so no-JS contexts still work), render ```mermaid``` fences through the same sandboxed Mermaid iframe as whole-document Mermaid artifacts (one lazily created iframe per diagram, capped by `ARTIFACTY_MAX_INLINE_DIAGRAMS`, default 20; extra diagrams stay as escaped code), render task lists as disabled checkboxes, and wrap tables in a horizontally scrolling container. Inline HTML in Markdown is always escaped.
569
+ - Notebook (`.ipynb`) artifacts store the original notebook JSON unchanged; the viewer renders cells in order (Markdown cells through the same embedded Markdown pipeline, code cells as highlighted escaped source with their execution count), with outputs limited to `text/plain`, `text/markdown`, `image/png`, `image/jpeg`, `image/svg+xml` (scriptless sandboxed iframe), and `text/html` (sandboxed iframe); other output MIME types show a placeholder, outputs over 2 MB are replaced with a truncated notice, and rendering is bounded to the first 500 cells.
370
570
  - Artifact content should still be treated as untrusted; use the raw view when handing content back to an agent.
371
571
  - npm releases are published with GitHub Actions OIDC Trusted Publishing after lint, test, and smoke checks pass.
372
572
 
373
573
  See [SECURITY.md](SECURITY.md), [docs/threat-model.md](docs/threat-model.md), and [docs/release-checklist.md](docs/release-checklist.md) before publishing or running a shared instance.
574
+
575
+ ## Environment Variables
576
+
577
+ Every `ARTIFACTY_*` variable Artifacty reads, most already covered in context above. `node src/cli.js help` prints the same list.
578
+
579
+ | Variable | Purpose | Default |
580
+ | --- | --- | --- |
581
+ | `ARTIFACTY_HOME` | Storage directory | `~/.artifacty` |
582
+ | `ARTIFACTY_URL` | Public URL override; otherwise CLI/MCP read the last running server URL | — |
583
+ | `ARTIFACTY_HOST` | Bind host for the HTTP server | `127.0.0.1` |
584
+ | `ARTIFACTY_PORT` | Bind port for the HTTP server | `8787` |
585
+ | `ARTIFACTY_MCP_URL` | Central MCP HTTP endpoint used by bridge mode | — |
586
+ | `ARTIFACTY_MCP_MODE` | `local` or `bridge`; `bridge` forwards stdio MCP to `ARTIFACTY_MCP_URL` | `local` |
587
+ | `ARTIFACTY_MCP_HTTP` | Set `true` to also expose MCP over HTTP on the running server | `false` |
588
+ | `ARTIFACTY_MCP_TIMEOUT_MS` | Bridge-mode MCP HTTP request timeout | `30000` |
589
+ | `ARTIFACTY_API_TOKEN` | Shared token required for HTTP API and LAN/team sharing | — |
590
+ | `ARTIFACTY_LOCALE` | Default UI locale (e.g. `en`, `ko`) when a request does not specify one | `en` |
591
+ | `ARTIFACTY_EMBEDDINGS_URL` | openai-compatible embeddings endpoint base URL; enables semantic/hybrid search | — |
592
+ | `ARTIFACTY_EMBEDDINGS_MODEL` | Embeddings model name | provider default |
593
+ | `ARTIFACTY_EMBEDDINGS_API_KEY` | API key for the openai-compatible provider; never logged or stored | — |
594
+ | `ARTIFACTY_EMBEDDINGS_COMMAND` | Local command that reads JSON lines on stdin and writes vectors on stdout | — |
595
+ | `ARTIFACTY_EMBEDDINGS_MAX_CHARS` | Max content characters embedded per artifact | `8000` |
596
+ | `ARTIFACTY_EMBEDDINGS_TIMEOUT_MS` | Timeout for the local embeddings command | `30000` |
597
+ | `ARTIFACTY_EMBEDDINGS_MAX_CANDIDATES` | Max embedding rows scored per semantic/hybrid search query | `20000` |
598
+ | `ARTIFACTY_EMBEDDINGS_SYNC` | Set `true` to index embeddings synchronously instead of in the background (used by tests) | `false` |
599
+ | `ARTIFACTY_SHARE_MODE` | `lan` or `team`, required before binding outside localhost | — |
600
+ | `ARTIFACTY_TEAM_WRITE` | Set `owner` to restrict writes on `team`-visibility artifacts to the owner or an admin | — |
601
+ | `ARTIFACTY_ALLOW_SECRETS` | Set `true` only to intentionally store content that matches a detected secret pattern | `false` |
602
+ | `ARTIFACTY_ENABLE_REACT_RENDERER` | Set `true` to execute React artifacts in a sandboxed frame instead of source-only | `false` |
603
+ | `ARTIFACTY_RETENTION_INTERVAL_MS` | Background retention sweep interval | `3600000` (1 hour) |
604
+ | `ARTIFACTY_RETENTION_ALLOW_PURGE` | Set `true` to allow retention sweeps to hard-delete archived artifacts | `false` |
605
+ | `ARTIFACTY_WEBHOOK_TIMEOUT_MS` | Per-attempt webhook delivery timeout | `10000` |
606
+ | `ARTIFACTY_WEBHOOK_ALLOW_PRIVATE` | Set `true` to allow webhook URLs that resolve to private/loopback addresses | `false` |
607
+ | `ARTIFACTY_EVENT_POLL_MS` | SSE poll interval for new events | `1000` |
608
+ | `ARTIFACTY_EVENT_HISTORY` | Max in-memory event history retained for replay | `10000` |
609
+ | `ARTIFACTY_SSE_MAX_CLIENTS` | Max concurrent SSE connections | `64` |
610
+ | `ARTIFACTY_MAX_WAITS` | Max concurrent `artifacty_wait` MCP long-polls | `64` |
611
+ | `ARTIFACTY_MAX_DIFF_ENTRIES` | Max diff entries computed per `artifacty diff` | `5000` |
612
+ | `ARTIFACTY_MAX_INLINE_DIAGRAMS` | Max inline Mermaid diagrams rendered per Markdown artifact | `20` |
613
+ | `ARTIFACTY_MAX_COMMENTS_PER_ARTIFACT` | Max comments retained per artifact | `2000` |
614
+ | `ARTIFACTY_MAX_SAVED_VIEWS_PER_USER` | Max saved views retained per user | `100` |
615
+ | `ARTIFACTY_RATE_LIMIT` | `always` or `off`; overrides the default (disabled on loopback, enabled otherwise) | — |
616
+ | `ARTIFACTY_RATE_WRITE_PER_MIN` | Rate limit for mutating routes | `120`/min |
617
+ | `ARTIFACTY_RATE_AUTH_PER_MIN` | Rate limit for `/login` per address | `10`/min |
618
+ | `ARTIFACTY_RATE_SEARCH_PER_MIN` | Rate limit for `GET /api/artifacts?q=` searches | `300`/min |
@@ -14,6 +14,9 @@ Artifacty schema v1 defines the stable envelope shared by HTTP, CLI, MCP, conver
14
14
  "publisherId": "user@example.com",
15
15
  "publisherName": "User Name",
16
16
  "publisherUserId": "user-record-id",
17
+ "visibility": "team",
18
+ "ownerUserId": "user-record-id",
19
+ "reviewStatus": "none",
17
20
  "tags": ["handoff"],
18
21
  "createdAt": "2026-06-24T00:00:00.000Z",
19
22
  "updatedAt": "2026-06-24T00:00:00.000Z",
@@ -56,6 +59,25 @@ Known source-agent aliases are canonicalized before storage. For example,
56
59
  Existing `unknown` rows are upgraded only when version metadata, audit data, or
57
60
  source-agent tags provide one of the known agent identities.
58
61
 
62
+ `visibility` is `"private"` or `"team"` (default `"team"`) and `ownerUserId`
63
+ identifies the artifact's owner. Once any user account exists, `team`
64
+ artifacts are readable and writable by any authenticated principal (writes
65
+ can be restricted to the owner or an admin with `ARTIFACTY_TEAM_WRITE=owner`);
66
+ `private` artifacts are readable and writable only by their owner or an
67
+ admin. Archiving, restoring, and changing visibility or ownership always
68
+ require the owner or an admin. In single-user mode (no user accounts) these
69
+ checks do not apply. `ownerUserId` defaults to the creating user (from
70
+ `publisherUserId`) and is backfilled the same way on migration for older
71
+ rows. See the Security Model section of the README for the full rule table.
72
+
73
+ `reviewStatus` is one of `none` (default), `pending`, `changes-requested`, or
74
+ `approved`. It is set explicitly via `POST /api/artifacts/:id/review-status`
75
+ (or `artifacty_set_review_status`, or `artifacty review-status`), which
76
+ requires the artifact's owner or an admin, and resets to `pending`
77
+ automatically — noted in that `update` audit row's metadata as
78
+ `reviewStatusReset: { from: "approved", to: "pending" }` — whenever a new
79
+ version is appended after an `approved` status.
80
+
59
81
  ## Version Record
60
82
 
61
83
  Normal create and update flows append versions, and each version points at one
@@ -77,7 +99,7 @@ write `version-repair` or `version-delete` audit events.
77
99
  ```
78
100
 
79
101
  Allowed `format` values are `html`, `markdown`, `text`, `json`, `code`, `svg`,
80
- `mermaid`, `react`, `sarif`, `csv`, `image`, and `video`.
102
+ `mermaid`, `react`, `sarif`, `csv`, `image`, `video`, and `notebook`.
81
103
 
82
104
  Common `artifactType` values include `document`, `handoff`, `code-review`,
83
105
  `test-report`, `dashboard`, `bundle`, `diagram`, `component`, `snippet`,
@@ -103,6 +125,27 @@ viewer concern and must treat all source as untrusted:
103
125
  - `image`: base64 media source rendered with `<img>`; `/raw` decodes bytes.
104
126
  - `video`: base64 media source rendered with `<video controls>`; `/raw` decodes
105
127
  bytes.
128
+ - `markdown`: fenced code blocks render as server-escaped
129
+ `<pre><code class="language-x">`, upgraded client-side to a read-only
130
+ CodeMirror view using the vendored language packages; the escaped markup
131
+ alone is already correct without JS. ```mermaid``` fences render through
132
+ the same sandboxed Mermaid iframe as whole-document Mermaid artifacts, one
133
+ iframe per diagram created lazily via `IntersectionObserver`, capped per
134
+ document by `ARTIFACTY_MAX_INLINE_DIAGRAMS` (default 20; fences beyond the
135
+ cap stay as escaped source with a notice). Task list items (`- [ ]`/`- [x]`)
136
+ render as disabled checkboxes; tables render inside a horizontally
137
+ scrolling container. Inline HTML in Markdown source is always escaped, not
138
+ interpreted.
139
+ - `notebook`: stored content is the original `.ipynb` JSON, parsed only for
140
+ rendering (parse failures fail closed to formatted JSON). Cells render in
141
+ order: Markdown cells through the same Markdown pipeline above, code cells
142
+ as escaped highlighted source with their execution count. Cell outputs are
143
+ limited to `text/plain`, `text/markdown`, `image/png`, `image/jpeg`
144
+ (decoded from base64), `image/svg+xml` (scriptless sandboxed iframe), and
145
+ `text/html` (sandboxed iframe); other MIME types show a placeholder naming
146
+ the type, `stream`/`error` outputs render as escaped text, and any output
147
+ over 2 MB is replaced with a truncated notice. Rendering is bounded to the
148
+ first 500 cells, with a notice when a notebook has more.
106
149
 
107
150
  ## Metadata
108
151
 
@@ -121,6 +164,104 @@ Metadata is free-form JSON, but converter-generated metadata uses these keys:
121
164
  metadata, such as `codexContinuation`, `copilotContinuation`, or
122
165
  `cursorContinuation`.
123
166
 
167
+ ## Relations
168
+
169
+ Artifacts can carry typed, directional relations to other artifacts, stored in
170
+ an `artifact_relations` table (`from_id`, `to_id`, `relation`, `created_at`,
171
+ `created_by`, `metadata_json`, unique per `(from_id, to_id, relation)`).
172
+
173
+ Allowed `relation` values (closed set for v1):
174
+
175
+ - `derived-from` — `from` was produced by reading `to`
176
+ - `supersedes` — `from` replaces `to`
177
+ - `reviews` — `from` is a review of `to`
178
+ - `references` — loose citation
179
+ - `part-of` — `from` belongs to bundle/collection `to`
180
+
181
+ Inverse names are computed, not stored, and shown on the other artifact:
182
+ `derived-from` ↔ `derives`, `supersedes` ↔ `superseded-by`, `reviews` ↔
183
+ `reviewed-by`, `references` ↔ `referenced-by`, `part-of` ↔ `contains`.
184
+
185
+ `createArtifact` and `updateArtifact` accept an optional `relations` array
186
+ (`[{ toId, relation }]`) to link an artifact in the same call it is created or
187
+ updated. `getArtifact` responses include:
188
+
189
+ ```json
190
+ {
191
+ "relations": {
192
+ "outgoing": [
193
+ { "id": "rel-id", "relation": "derived-from", "artifactId": "other-id", "artifact": { "...": "summary" }, "missing": false, "createdAt": "2026-06-24T00:00:00.000Z" }
194
+ ],
195
+ "incoming": []
196
+ }
197
+ }
198
+ ```
199
+
200
+ `artifact` is a summary of the other side of the link, or `null` with
201
+ `missing: true` when the target artifact no longer exists — a dangling
202
+ relation is reported, not silently dropped. Self-links (`fromId === toId`) and
203
+ relation names outside the closed set are rejected.
204
+
205
+ `GET /api/artifacts` and `artifacty_list` accept `relatedTo=<id>` (and
206
+ optionally `relation=<name>`) to restrict results to artifacts linked, in
207
+ either direction, to a given artifact.
208
+
209
+ ## Comments and Review Status
210
+
211
+ Comments are lightweight, version-anchored notes stored in an
212
+ `artifact_comments` table (`id`, `artifact_id`, `version`, `parent_id`,
213
+ `author_user_id`, `author_label`, `source_agent`, `body`, `anchor_json`,
214
+ `status`, `created_at`, `resolved_at`, `resolved_by`, `deleted_at`).
215
+
216
+ ```json
217
+ {
218
+ "id": "comment-id",
219
+ "artifactId": "artifact-id",
220
+ "version": 3,
221
+ "parentId": null,
222
+ "authorUserId": "user-record-id",
223
+ "authorLabel": "User Name",
224
+ "sourceAgent": "mcp",
225
+ "body": "Please add a test for the empty-input case.",
226
+ "anchor": { "line": 42 },
227
+ "status": "open",
228
+ "createdAt": "2026-06-24T00:00:00.000Z",
229
+ "resolvedAt": null,
230
+ "resolvedBy": null,
231
+ "deletedAt": null
232
+ }
233
+ ```
234
+
235
+ - `version` defaults to the artifact's `latestVersion` when omitted.
236
+ - `body` is Markdown, rendered through the same sanitized Markdown pipeline as
237
+ markdown-format artifact content, and capped at 16 KB (`MAX_COMMENT_BYTES`).
238
+ - `anchor` is optional and format-specific — `{ "line": 42 }` for text
239
+ formats, `{ "path": "$.runs[0].results[3]" }` for JSON/SARIF, `{ "row": 7 }`
240
+ for CSV — and is a rendering hint only, not validated against the
241
+ artifact's actual content.
242
+ - Threads are one level deep: `parentId`, when set, must point to a comment
243
+ that itself has no `parentId` (a root comment); replying to a reply is
244
+ rejected with `code: "THREAD_TOO_DEEP"`.
245
+ - `status` is `"open"` or `"resolved"`, set via the resolve action
246
+ (`resolvedAt`/`resolvedBy` are then populated).
247
+ - Deleting a comment is a soft delete: `deletedAt` is set and the row is
248
+ hidden from `listComments`/`GET .../comments` by default (pass
249
+ `includeDeleted: true` to see it), but the comment row and its
250
+ `comment-delete` audit-log entry are both kept.
251
+ - Comments on a `private` artifact are readable and writable under the same
252
+ owner/admin rules as the artifact itself (see Visibility above).
253
+ - `author_label` is derived from the caller's audit context (the
254
+ authenticated user's display name/email, falling back to the publisher id,
255
+ the `sourceAgent`, or `"anonymous"`).
256
+
257
+ `GET`/`POST /api/artifacts/:id/comments`,
258
+ `POST /api/artifacts/:id/comments/:commentId/resolve`, and
259
+ `DELETE /api/artifacts/:id/comments/:commentId` manage comments over HTTP.
260
+ `artifacty_comment` and `artifacty_resolve_comment` are the MCP equivalents;
261
+ the MCP `artifacty_get` tool additionally accepts `includeComments: true` to
262
+ return the requested version's open comments alongside the artifact (there is
263
+ no HTTP equivalent flag — fetch `GET /api/artifacts/:id/comments` separately).
264
+
124
265
  ## Archive Semantics
125
266
 
126
267
  Artifacts are not deleted by P0 behavior. Archive sets `archivedAt` and hides the artifact from default list results. `includeArchived=true` includes archived records. Restore clears `archivedAt`. Regular archive/restore leaves versions and content files unchanged. Administrators may repair or delete individual versions from `/admin/artifacts/:id/versions`; the last remaining version cannot be deleted.
@@ -148,6 +289,51 @@ Bundles are JSON artifacts with `artifactType: "bundle"` and content type `appli
148
289
 
149
290
  Gemini multimodal payloads use the same bundle type with `parts` and `assets`.
150
291
 
292
+ ### Document Assets
293
+
294
+ A `files` entry may carry binary document content instead of inline text by
295
+ setting `encoding: "base64"` and a `contentType` from the allowed binary
296
+ types: `application/pdf`,
297
+ `application/vnd.openxmlformats-officedocument.wordprocessingml.document`
298
+ (`.docx`), `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`
299
+ (`.xlsx`), and `application/zip`. `content` holds the base64-encoded bytes;
300
+ `sizeBytes` and `sha256` describe the *decoded* bytes.
301
+
302
+ ```json
303
+ {
304
+ "path": "report.pdf",
305
+ "contentType": "application/pdf",
306
+ "encoding": "base64",
307
+ "content": "JVBERi0xLjQ...",
308
+ "sizeBytes": 20480,
309
+ "sha256": "..."
310
+ }
311
+ ```
312
+
313
+ Rules enforced on every write (`createArtifact`/`updateArtifact` and
314
+ conversion via `convertAgentArtifact`):
315
+
316
+ - Each binary file entry is capped at 32 MB decoded; the bundle artifact as a
317
+ whole still has to fit under the general `MAX_ARTIFACT_BYTES` limit
318
+ (16 MB by default for text bundles; a bundle with document assets can
319
+ legitimately exceed that only if `MAX_ARTIFACT_BYTES` is raised, since the
320
+ base64 text itself counts toward the artifact size).
321
+ - `path` (or `name`) must be a safe relative path: no `..` segments, no
322
+ absolute or drive-letter paths, no control characters.
323
+ - An unlisted `contentType` on a binary entry is rejected.
324
+ - Secret scanning is skipped for binary file content (and for binary asset
325
+ `data`), since it is not text, but file/asset names and any non-binary
326
+ `files`/`assets` text content are still scanned.
327
+
328
+ The viewer renders PDF entries in a sandboxed iframe (no `allow-same-origin`,
329
+ no scripts) pointing at `GET /artifacts/:id/raw?file=<path>`; other document
330
+ types show metadata and a download link to the same route. That route
331
+ responds with the entry's `contentType`, `X-Content-Type-Options: nosniff`,
332
+ `Cache-Control: private`, and `Content-Disposition: inline` for PDFs or
333
+ `attachment` otherwise. It honors the same `access` rules as other artifact
334
+ reads (private-artifact non-owners get 404), and returns 404 for an unknown
335
+ `file` name.
336
+
151
337
  ## Asset Policy
152
338
 
153
339
  Base64 assets are preserved inline inside bundle JSON with `encoding: "base64"`, `mimeType`, `sizeBytes`, and `sha256`. Consumers must treat decoded assets as untrusted.