User artifacts are never rewritten, but a secret pattern in a version marks the artifact restricted and audits the reason (review a2-C, SO-12). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RNPhwu2bsaRNg3DETSAZoM
425 lines
19 KiB
Python
425 lines
19 KiB
Python
"""HUX-04 artifact workspace: typed, versioned, diffable outputs.
|
|
|
|
Every artifact is one ``hux.artifact.v1`` document under the caller's tenant
|
|
subtree. Content lives in the content-addressed blob store; the document only
|
|
carries ``content_ref`` hashes, so a version can never be rewritten once it
|
|
has been appended (SO-30). Blobs are served inside a JSON envelope with
|
|
``nosniff`` and never as an executable response type (SO-32). Every
|
|
referenced id is resolved under the caller's own subtree (SO-33); a foreign
|
|
or unknown id is ``404`` so ids cannot be probed. Sharing is out of scope for
|
|
this increment: ``access.mode`` is always ``owner`` (SO-34).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import hashlib
|
|
from typing import Any
|
|
|
|
from hux import diffs
|
|
from hux.contracts import load_all, validate_record
|
|
from hux.errors import Conflict, Invalid, NotFound, TooLarge
|
|
from hux.http import Request, Response, Router, page
|
|
from hux.identity import Identity
|
|
from hux.store import TenantStore, check_id, new_id, now_iso
|
|
|
|
|
|
MAX_UPLOAD_BODY = 34 * 1024 * 1024 # 25 MiB of content plus base64 overhead (SO-31, SO-54)
|
|
|
|
|
|
def _project_exists(store, project_id: str) -> bool:
|
|
"""Ask the organization family when it is present; otherwise only the id shape is known."""
|
|
try:
|
|
from hux.organization import project_exists
|
|
except ModuleNotFoundError: # pragma: no cover - organization is always shipped with artifacts
|
|
return True
|
|
return project_exists(store, project_id)
|
|
|
|
FAMILY = "artifacts"
|
|
MAX_VERSION_BYTES = 25 * 1024 * 1024
|
|
MAX_VERSIONS = 200
|
|
MAX_ARTIFACTS = 2000
|
|
PAGE_SIZE = 50
|
|
ARTIFACT_TYPES = ("markdown", "code", "html", "svg", "image", "json", "csv", "document", "audio")
|
|
SENSITIVITIES = ("public", "personal", "sensitive", "restricted")
|
|
_SCHEMAS: dict[str, dict[str, Any]] = {}
|
|
|
|
|
|
def _schemas() -> dict[str, dict[str, Any]]:
|
|
if not _SCHEMAS:
|
|
_SCHEMAS.update(load_all())
|
|
return _SCHEMAS
|
|
|
|
|
|
# -- helpers shared with other lanes -------------------------------------------
|
|
|
|
def artifact_exists(store: TenantStore, artifact_id: str) -> bool:
|
|
"""True when ``artifact_id`` is a well-formed id owned by the store's tenant."""
|
|
try:
|
|
return store.exists(FAMILY, artifact_id)
|
|
except Invalid:
|
|
return False
|
|
|
|
|
|
def artifact_titles(store: TenantStore, ids: list[str]) -> dict[str, str]:
|
|
"""Map each resolvable artifact id to its title; unknown ids are omitted."""
|
|
titles: dict[str, str] = {}
|
|
for artifact_id in ids:
|
|
if artifact_exists(store, artifact_id):
|
|
titles[artifact_id] = store.get(FAMILY, artifact_id)["title"]
|
|
return titles
|
|
|
|
|
|
def emit_event(store: TenantStore, identity: Identity, artifact: dict[str, Any], kind: str, summary: str, detail: dict[str, Any]) -> None:
|
|
"""Record an activity event through the events lane when it is present.
|
|
|
|
Imported lazily so module import order never matters; a missing events
|
|
module (earlier build) is not an error because the artifact write itself
|
|
is the source of truth.
|
|
"""
|
|
conversation_id = artifact.get("conversation_id")
|
|
if conversation_id is None:
|
|
return
|
|
try:
|
|
from hux.events import emit
|
|
except ModuleNotFoundError:
|
|
return
|
|
version = artifact["current_version"]
|
|
evidence = [{"kind": "artifact_version", "id": f"{artifact['id']}@{version}", "hash": artifact["versions"][-1]["content_ref"]["hash"]}]
|
|
emit(store, identity, conversation_id, kind, summary, detail=detail, evidence=evidence, sensitivity=artifact["sensitivity"])
|
|
|
|
|
|
def replay(store: TenantStore, family: str, key: str) -> str | None:
|
|
"""Record id previously created under an Idempotency-Key, if any."""
|
|
if not key:
|
|
return None
|
|
for row in store.read(family, "idempotency"):
|
|
if row.get("key") == key:
|
|
return row["id"]
|
|
return None
|
|
|
|
|
|
def remember(store: TenantStore, family: str, key: str, record_id: str) -> None:
|
|
"""Persist an Idempotency-Key to record id mapping."""
|
|
if key:
|
|
store.append(family, "idempotency", {"key": key, "id": record_id, "at": now_iso()})
|
|
|
|
|
|
# -- record shaping ------------------------------------------------------------
|
|
|
|
def _actor(identity: Identity) -> dict[str, str]:
|
|
if identity.trust == "worker":
|
|
return {"type": "system", "id": "worker"}
|
|
return {"type": "user", "id": identity.subject}
|
|
|
|
|
|
def _body(request: Request) -> dict[str, Any]:
|
|
if not isinstance(request.body, dict):
|
|
raise Invalid("body must be a JSON object")
|
|
return request.body
|
|
|
|
|
|
def _string(body: dict[str, Any], key: str, limit: int, required: bool = True) -> str | None:
|
|
value = body.get(key)
|
|
if value is None:
|
|
if required:
|
|
raise Invalid(f"{key} is required")
|
|
return None
|
|
if not isinstance(value, str) or not value.strip() or len(value) > limit:
|
|
raise Invalid(f"{key} must be a non-empty string of at most {limit} characters")
|
|
return value
|
|
|
|
|
|
def secret_hits(data: bytes) -> list[str]:
|
|
"""Secret-pattern classes found in text content; binary content is not scanned (SO-12)."""
|
|
from hux.redaction import scrub_text
|
|
|
|
try:
|
|
text = data.decode("utf-8")
|
|
except UnicodeDecodeError:
|
|
return []
|
|
return scrub_text(text)[1]
|
|
|
|
|
|
def _content(body: dict[str, Any]) -> tuple[bytes, str]:
|
|
"""Decode the submitted content and return ``(bytes, mime)``."""
|
|
text, encoded = body.get("content"), body.get("content_base64")
|
|
if isinstance(text, str) and encoded is None:
|
|
data = text.encode("utf-8")
|
|
elif isinstance(encoded, str) and text is None:
|
|
try:
|
|
data = base64.b64decode(encoded, validate=True)
|
|
except (ValueError, TypeError) as error:
|
|
raise Invalid("content_base64 is not valid base64") from error
|
|
else:
|
|
raise Invalid("exactly one of content (utf-8 text) or content_base64 is required")
|
|
if len(data) > MAX_VERSION_BYTES:
|
|
raise TooLarge("version exceeds 25 MiB")
|
|
mime = _string(body, "mime", 120, required=False) or "application/octet-stream"
|
|
return data, mime
|
|
|
|
|
|
def _store_content(store: TenantStore, body: dict[str, Any]) -> dict[str, Any]:
|
|
"""Hash, verify against any client-supplied hash, store the blob, return ``content_ref``."""
|
|
data, mime = _content(body)
|
|
digest = hashlib.sha256(data).hexdigest()
|
|
claimed = body.get("hash")
|
|
if claimed is not None and claimed != f"sha256:{digest}":
|
|
raise Invalid("hash does not match the submitted content")
|
|
store.put_blob(digest, data)
|
|
return {"hash": f"sha256:{digest}", "bytes": len(data), "mime": mime}
|
|
|
|
|
|
def _optional_id(body: dict[str, Any], key: str) -> str | None:
|
|
value = body.get(key)
|
|
return None if value is None else check_id(value)
|
|
|
|
|
|
def _load_owned(request: Request) -> dict[str, Any]:
|
|
"""The artifact named in the path; unknown, malformed or foreign is 404."""
|
|
try:
|
|
artifact = request.store.get(FAMILY, request.params["id"])
|
|
except Invalid as error:
|
|
raise NotFound("artifact not found") from error
|
|
if artifact.get("owner") != request.identity.subject:
|
|
raise NotFound("artifact not found")
|
|
return artifact
|
|
|
|
|
|
def _version(artifact: dict[str, Any], number: Any) -> dict[str, Any]:
|
|
if not isinstance(number, int) or isinstance(number, bool):
|
|
raise Invalid("version must be an integer")
|
|
for entry in artifact["versions"]:
|
|
if entry["version"] == number:
|
|
return entry
|
|
raise NotFound(f"version {number} not found")
|
|
|
|
|
|
def _lineage(request: Request, body: dict[str, Any]) -> dict[str, Any] | None:
|
|
"""Resolve ``lineage`` under the caller's subtree; anything else is 404 (SO-33)."""
|
|
lineage = body.get("lineage")
|
|
if lineage is None:
|
|
return None
|
|
if not isinstance(lineage, dict):
|
|
raise Invalid("lineage must be an object")
|
|
try:
|
|
parent = request.store.get(FAMILY, check_id(lineage.get("artifact_id")))
|
|
except (Invalid, NotFound) as error:
|
|
raise NotFound("lineage artifact not found") from error
|
|
if parent.get("owner") != request.identity.subject:
|
|
raise NotFound("lineage artifact not found")
|
|
version = _version(parent, lineage.get("version"))
|
|
return {"artifact_id": parent["id"], "version": version["version"]}
|
|
|
|
|
|
def append_version(store: TenantStore, artifact: dict[str, Any], entry: dict[str, Any], expected_revision: int | None) -> dict[str, Any]:
|
|
"""Append an immutable version entry and persist the document.
|
|
|
|
The version number must be exactly ``current_version + 1``; any attempt to
|
|
write an existing number is a Conflict so history is never rewritten.
|
|
"""
|
|
if any(v["version"] == entry["version"] for v in artifact["versions"]):
|
|
raise Conflict(f"version {entry['version']} already exists")
|
|
if entry["version"] != artifact["current_version"] + 1:
|
|
raise Conflict("versions are appended in order")
|
|
if len(artifact["versions"]) >= MAX_VERSIONS:
|
|
raise TooLarge("artifact has reached 200 versions")
|
|
updated = {**artifact, "versions": [*artifact["versions"], entry], "current_version": entry["version"], "updated_at": now_iso()}
|
|
_check(updated)
|
|
return store.put(FAMILY, updated, expected_revision=expected_revision)
|
|
|
|
|
|
def _check(artifact: dict[str, Any]) -> None:
|
|
problems = validate_record(artifact, _schemas())
|
|
if problems:
|
|
raise Invalid("artifact does not satisfy hux.artifact.v1", problems)
|
|
|
|
|
|
# -- handlers ------------------------------------------------------------------
|
|
|
|
def create(request: Request) -> Response:
|
|
"""``POST /hux/v1/artifacts``: create an artifact with version 1."""
|
|
body = _body(request)
|
|
key = request.idempotency_key()
|
|
with request.store.lock(FAMILY):
|
|
existing = replay(request.store, FAMILY, key)
|
|
if existing is not None:
|
|
request.audit("artifacts.create", existing, reason="idempotent_replay")
|
|
return Response(200, request.store.get(FAMILY, existing), {"HUX-Replayed": "true"})
|
|
if request.store.count(FAMILY) >= MAX_ARTIFACTS:
|
|
raise TooLarge("tenant has reached 2000 artifacts")
|
|
artifact_type = _string(body, "type", 40)
|
|
if artifact_type not in ARTIFACT_TYPES:
|
|
raise Invalid("unknown artifact type")
|
|
sensitivity = body.get("sensitivity", "personal")
|
|
if sensitivity not in SENSITIVITIES:
|
|
raise Invalid("unknown sensitivity")
|
|
hits = secret_hits(_content(body)[0])
|
|
if hits:
|
|
sensitivity = "restricted"
|
|
stamp = now_iso()
|
|
version: dict[str, Any] = {"version": 1, "created_at": stamp, "created_by": _actor(request.identity), "content_ref": _store_content(request.store, body)}
|
|
for field, limit in (("message_id", 120), ("note", 200)):
|
|
if _string(body, field, limit, required=False) is not None:
|
|
version[field] = body[field]
|
|
lineage = _lineage(request, body)
|
|
if lineage is not None:
|
|
version["lineage"] = lineage
|
|
artifact: dict[str, Any] = {
|
|
"schema": "hux.artifact.v1",
|
|
"id": new_id("art"),
|
|
"owner": request.identity.subject,
|
|
"type": artifact_type,
|
|
"title": _string(body, "title", 200),
|
|
"current_version": 1,
|
|
"versions": [version],
|
|
"sensitivity": sensitivity,
|
|
"created_at": stamp,
|
|
"updated_at": stamp,
|
|
"revision": 1,
|
|
"access": {"mode": "owner"},
|
|
}
|
|
for field in ("conversation_id", "project_id"):
|
|
if _optional_id(body, field) is not None:
|
|
artifact[field] = body[field]
|
|
if _string(body, "language", 40, required=False) is not None:
|
|
artifact["language"] = body["language"]
|
|
_check(artifact)
|
|
stored = request.store.put(FAMILY, artifact)
|
|
remember(request.store, FAMILY, key, stored["id"])
|
|
request.audit("artifacts.create", stored["id"], reason="secret_pattern_restricted" if hits else "")
|
|
emit_event(request.store, request.identity, stored, "artifact.created", f"Created {stored['type']} artifact", {"artifact_id": stored["id"], "version": 1})
|
|
return Response(201, stored, {"ETag": str(stored["revision"])})
|
|
|
|
|
|
def list_artifacts(request: Request) -> Response:
|
|
"""``GET /hux/v1/artifacts?conversation_id=&project_id=&cursor=``: owned artifacts, oldest first."""
|
|
filters = {k: request.query[k] for k in ("conversation_id", "project_id") if request.query.get(k)}
|
|
cursor = request.query.get("cursor", "0")
|
|
if not cursor.isdigit():
|
|
raise Invalid("cursor must be a non-negative integer")
|
|
items = [
|
|
artifact
|
|
for artifact in request.store.scan(FAMILY)
|
|
if artifact.get("owner") == request.identity.subject and all(artifact.get(k) == v for k, v in filters.items())
|
|
]
|
|
start = int(cursor)
|
|
window = items[start : start + PAGE_SIZE]
|
|
request.audit("artifacts.list", "artifacts")
|
|
return page(window, str(start + PAGE_SIZE) if len(items) > start + PAGE_SIZE else None)
|
|
|
|
|
|
def get(request: Request) -> Response:
|
|
"""``GET /hux/v1/artifacts/{id}``: one artifact document."""
|
|
artifact = _load_owned(request)
|
|
request.audit("artifacts.get", artifact["id"])
|
|
return Response(200, artifact, {"ETag": str(artifact["revision"])})
|
|
|
|
|
|
def add_version(request: Request) -> Response:
|
|
"""``POST /hux/v1/artifacts/{id}/versions``: append a new immutable version (If-Match required)."""
|
|
body = _body(request)
|
|
expected = request.if_match()
|
|
if expected is None:
|
|
raise Invalid("If-Match is required to append a version")
|
|
key = request.idempotency_key()
|
|
with request.store.lock(FAMILY):
|
|
artifact = _load_owned(request)
|
|
existing = replay(request.store, FAMILY, key)
|
|
if existing is not None and existing.split("@")[0] == artifact["id"]:
|
|
request.audit("artifacts.version", existing, reason="idempotent_replay")
|
|
return Response(200, artifact, {"HUX-Replayed": "true", "ETag": str(artifact["revision"])})
|
|
if expected != artifact["revision"]:
|
|
raise Conflict(f"revision {expected} does not match current revision {artifact['revision']}", [str(artifact["revision"])])
|
|
number = artifact["current_version"] + 1
|
|
hits = secret_hits(_content(body)[0])
|
|
if hits:
|
|
artifact = {**artifact, "sensitivity": "restricted"}
|
|
entry: dict[str, Any] = {"version": number, "created_at": now_iso(), "created_by": _actor(request.identity), "content_ref": _store_content(request.store, body), "diff_from": artifact["current_version"]}
|
|
if body.get("diff_from") is not None:
|
|
entry["diff_from"] = _version(artifact, body["diff_from"])["version"]
|
|
for field, limit in (("message_id", 120), ("note", 200)):
|
|
if _string(body, field, limit, required=False) is not None:
|
|
entry[field] = body[field]
|
|
lineage = _lineage(request, body)
|
|
if lineage is not None:
|
|
entry["lineage"] = lineage
|
|
stored = append_version(request.store, artifact, entry, expected)
|
|
remember(request.store, FAMILY, key, f"{stored['id']}@{number}")
|
|
request.audit("artifacts.version", f"{stored['id']}@{number}", reason="secret_pattern_restricted" if hits else "")
|
|
emit_event(request.store, request.identity, stored, "artifact.version", f"New version {number}", {"artifact_id": stored["id"], "version": number})
|
|
return Response(201, stored, {"ETag": str(stored["revision"])})
|
|
|
|
|
|
def _version_number(request: Request) -> int:
|
|
raw = request.params["n"]
|
|
if not raw.isdigit() or int(raw) < 1:
|
|
raise Invalid("version must be a positive integer")
|
|
return int(raw)
|
|
|
|
|
|
def get_version(request: Request) -> Response:
|
|
"""``GET /hux/v1/artifacts/{id}/versions/{n}``: version metadata plus content."""
|
|
artifact = _load_owned(request)
|
|
entry = _version(artifact, _version_number(request))
|
|
data = request.store.get_blob(entry["content_ref"]["hash"].split(":", 1)[1])
|
|
text = diffs.decode_text(data) if diffs.is_text_type(artifact["type"]) else None
|
|
body: dict[str, Any] = {"artifact_id": artifact["id"], "type": artifact["type"], "version": entry}
|
|
if text is None:
|
|
body["content_base64"] = base64.b64encode(data).decode("ascii")
|
|
else:
|
|
body["content"] = text
|
|
request.audit("artifacts.get_version", f"{artifact['id']}@{entry['version']}")
|
|
headers = {"X-Content-Type-Options": "nosniff", "Content-Disposition": "attachment", "ETag": str(artifact["revision"])}
|
|
return Response(200, body, headers)
|
|
|
|
|
|
def diff(request: Request) -> Response:
|
|
"""``GET /hux/v1/artifacts/{id}/versions/{n}/diff?from=``: unified diff for text, sizes and hashes otherwise."""
|
|
artifact = _load_owned(request)
|
|
to_entry = _version(artifact, _version_number(request))
|
|
raw_from = request.query.get("from", "")
|
|
if raw_from and not raw_from.isdigit():
|
|
raise Invalid("from must be a version number")
|
|
from_number = int(raw_from) if raw_from else to_entry.get("diff_from", to_entry["version"])
|
|
from_entry = _version(artifact, from_number)
|
|
older = request.store.get_blob(from_entry["content_ref"]["hash"].split(":", 1)[1])
|
|
newer = request.store.get_blob(to_entry["content_ref"]["hash"].split(":", 1)[1])
|
|
request.audit("artifacts.diff", f"{artifact['id']}@{from_number}..{to_entry['version']}")
|
|
return Response(200, diffs.unified(artifact["type"], older, newer, from_number, to_entry["version"]), {"X-Content-Type-Options": "nosniff"})
|
|
|
|
|
|
def promote(request: Request) -> Response:
|
|
"""``POST /hux/v1/artifacts/{id}/promote``: mark the current version as the project's copy."""
|
|
body = _body(request)
|
|
project_id = check_id(body.get("project_id"))
|
|
if not _project_exists(request.store, project_id):
|
|
raise NotFound("project not found")
|
|
expected = request.if_match()
|
|
with request.store.lock(FAMILY):
|
|
artifact = _load_owned(request)
|
|
if expected is not None and expected != artifact["revision"]:
|
|
raise Conflict(f"revision {expected} does not match current revision {artifact['revision']}", [str(artifact["revision"])])
|
|
version = artifact["current_version"]
|
|
if body.get("version") is not None:
|
|
version = _version(artifact, body["version"])["version"]
|
|
stamp = now_iso()
|
|
updated = {**artifact, "project_id": project_id, "promotion": {"project_id": project_id, "version": version, "at": stamp}, "updated_at": stamp}
|
|
_check(updated)
|
|
stored = request.store.put(FAMILY, updated, expected_revision=artifact["revision"])
|
|
request.audit("artifacts.promote", f"{stored['id']}@{version}", reason="" if expected is not None else "unconditional_write")
|
|
emit_event(request.store, request.identity, stored, "artifact.promoted", f"Promoted version {version} to project", {"artifact_id": stored["id"], "version": version, "project_id": project_id})
|
|
return Response(200, stored, {"ETag": str(stored["revision"])})
|
|
|
|
|
|
def register(router: Router) -> None:
|
|
"""Attach HUX-04 routes."""
|
|
card = "HUX-04"
|
|
router.add("POST", "/hux/v1/artifacts", card, "artifacts.create", create, max_body=MAX_UPLOAD_BODY)
|
|
router.add("GET", "/hux/v1/artifacts", card, "artifacts.list", list_artifacts)
|
|
router.add("GET", "/hux/v1/artifacts/{id}", card, "artifacts.get", get)
|
|
router.add("POST", "/hux/v1/artifacts/{id}/versions", card, "artifacts.version", add_version, max_body=MAX_UPLOAD_BODY)
|
|
router.add("GET", "/hux/v1/artifacts/{id}/versions/{n}", card, "artifacts.get_version", get_version)
|
|
router.add("GET", "/hux/v1/artifacts/{id}/versions/{n}/diff", card, "artifacts.diff", diff)
|
|
router.add("POST", "/hux/v1/artifacts/{id}/promote", card, "artifacts.promote", promote)
|