Skip to content

Scoped local attachments

Attachments are an optional, local WebSocket API for small evidence objects. The Hub stores bytes in an owner-only directory and keeps their SHA-256 digest, length, media type, provenance, expiry, and project scope in a separate SQLite ledger. It never publishes bytes through the dashboard, HTTP API, manifest, or federated event log. An optional private recipient-granted peer API serves individual objects. A digest is an identifier, never a credential.

Enable the API with synapse hub --attachment-root /private/path alongside a durable --db, connect --token, --identity-trust and --require-identity-binding, --message-auth-key, --require-message-auth and a durable replay database, plus --acl-policy, --require-acl and --role-grants. The Hub refuses to start the attachment store unless all gates are present. The root must already have an owner-only parent; the Hub creates its root and staging and objects directories at mode 0700. Back up the ledger and object directory together. The bytes are not encrypted by the attachment store; use owner-controlled encrypted storage when disk confidentiality is required.

For identity proj/alice, the requested scope must be exactly proj. The owner grants both a role address (proj/attachment-read, proj/attachment-write, or proj/attachment-admin) and an ACL rule with the same permission on target kind attachment, target proj, namespace proj. Read, write, and admin grants are separate. Admin controls garbage collection. The Hub checks the signed, bound sender and both grants before touching a digest, upload ID, or object. Local requests remain project-bound; cross-hub reads use the separate source-owned permissions below.

The wire version is 4. Every request has the normal envelope plus scope; every request is signed with per-message authentication. Admitted requests receive a private attachment_result with operation, ok, and either result fields or error; the Hub's earlier signature and ACL gates return their ordinary private error frame on refusal.

Request Fields Result
attachment_begin digest (lowercase SHA-256), length, media_type, provenance, expires_at (Unix seconds) upload_id
attachment_chunk upload_id, sequential offset, base64 body received
attachment_commit upload_id verified metadata
attachment_abort upload_id completion
attachment_info digest private metadata
attachment_read digest, offset, optional preview: true base64 body, eof, optional escaped preview_html
attachment_ref digest, ref, optional remove: true completion
attachment_gc optional dry_run: false eligible digests; dry-run is default

The Python agent offers send_attachment(type, **fields) and dispatches results through its ordinary callback. The TypeScript client offers attachment(type, fields) with explicit registration and attachment signer callbacks. Both require a version-four welcome before emitting a request.

An object is at most 8 MiB, a chunk at most 32 KiB, a scope at most 256 MiB, and the Hub admits at most four active uploads. Staging is discarded on socket disconnect or restart; an incomplete upload is never readable. Commit verifies length and SHA-256 before atomic publication. Reads reject expired content and verify the full stored digest before yielding one bounded chunk. References keep expired objects until explicitly removed; new references cannot be added after expiry; admin GC deletes only expired objects with no references. The preview is available only for text/plain, decodes with replacement, escapes HTML, and is limited to the first 512 bytes of the first read chunk. Never render raw attachment bytes as HTML.

The Hub offers no anonymous public metadata listing. Publish a separate owner-reviewed reproducibility manifest with digest, length, provenance, license, and a stable owner-controlled download URL when distribution is intended; the content remains outside the public repository and public Hub surfaces. Large datasets, checkpoints, and model weights belong on owner-controlled storage, not in this bounded API or a public release.

Recipient-granted cross-hub reads (wire version 6)

Enable --attachment-recipient-policy FILE alongside the complete local attachment posture and --multihub-serving-policy FILE. The source hub refuses to start if either feature is absent. The recipient policy is a UTF-8 JSON file owned by the operator, mode 0600, without a symlink or hard-link alias, at most 65,536 bytes:

{
  "version": 1,
  "grants": [
    {
      "recipient_hub": "hub-b",
      "scope": "PROJECT",
      "digest": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
      "expires_at": 1790870400
    }
  ]
}

Use the actual object digest and a future Unix expiry. Each of at most 256 grants names exactly one recipient hub, project and lowercase SHA-256 digest. Wildcards, unknown or duplicate fields, duplicate recipient/object tuples and non-finite expiries are refused. An empty list grants nothing.

The source checks the requesting socket's pinned client certificate or verified identity-key registration through its peer serving policy, including namespace, peering expiry and revocation. The peering must explicitly grant the read verb for that namespace. A namespace grant alone never permits content. It then reloads the recipient file and checks its exact object grant and expiry before any digest lookup. Both metadata and chunks require these checks. Source content expiry also denies metadata, even when a reference retains the bytes.

Atomically replace the file with an owner-only replacement to revoke or revise permissions without restarting the hub. The next request, including a retry on an already-open socket, observes the replacement. A missing, invalid or newly public file denies all reads. One accepted request uses one policy snapshot; revocation cannot retract a chunk already authorised or delivered. Reads never add or remove source references and never change source garbage-collection rules.

A peer registers with protocol_version: 6 and sends attachment_peer_request with action (info or read), scope, digest, and, for read, an integer offset. The private attachment_peer_result returns ok: true plus metadata, or scope, digest, offset, base64 body and boolean eof. Every handler refusal is exactly ok: false, error: "attachment unavailable", without metadata or bytes. Earlier connection authentication failures retain their ordinary private refusal. The read-only peer frames use the verified connection's identity; local attachment frames retain their durable per-message authentication, ACL and role requirements. No peer upload, reference mutation, listing or GC operation is exposed.

The Python API is synapse_channel.core.attachment_transport.request_attachment. It checks the source hub id on every response, negotiates version six before requesting content, bounds frames and chunks, validates response fields and returns chunk bodies as bytes. Across hosts, supply a verifying ssl_context and wss:// URI; plain ws:// is refused outside numeric loopback or localhost. An identity-key registration can prove the recipient through a TLS proxy. The source id check supplements TLS server authentication. A supplied context must verify the server certificate and hostname, or the caller must pass an exact source_certificate_pin="sha256:<hex>". The live pin is checked immediately after the TLS handshake, before registration or a connection token is sent. This supports owner-pinned private certificates without treating a disabled CA check as trust. The caller validates the assembled length and complete SHA-256 digest before committing to its local Core store; interrupted local Core uploads still require a new session-bound token. Fleet transfer staging and resumability are separate consumer responsibilities. Older peers and clients keep their earlier local attachment and forwarding APIs.

Private owner read audit

The source's private attachments.sqlite3 ledger retains the most recent 256 peer read decisions across restarts. Each entry names the bound recipient, validated scope and digest, info/read (or invalid), timestamp and whether content was served. Malformed identifiers are omitted and recipient names are bounded to 128 characters. No bytes, provenance, connection tokens or grant contents are recorded. An atomic SQLite trigger bounds retention on every insert; a failed audit write refuses the read with the ordinary fixed unavailable result. The local owner API is AttachmentStore.peer_read_audit(). No seat, peer, HTTP or federated-log API exports these entries; filesystem custody remains owner-only.

Every chunk request reloads and parses the policy (at most 64 KiB and 256 grants) and writes one audit decision. This intentionally repeats file I/O to apply revocation on the next request. Existing source integrity checks also hash the complete bounded object before returning a chunk. A maximum 8 MiB object requires 256 data requests at the 32 KiB chunk limit; each incurs those checks. Keep large artifacts on owner-controlled artifact storage and tune transfer concurrency to the source's storage capacity.