Blob messaging — implementation draft
Status: core implemented with unit evidence; no claim of public deployment, archive format registration, malware scanning, or interoperable messaging clients. Decision sign-off: reviewer ___ / date ___ / outcome ___. See contact capabilities, notifications, and DEVLOG.
Model and persistence interface
Messages consist of immutable decoded bytes and owner-managed metadata: {id,owner,collection,mimeType,size,receivedAt,readAt,tags,classification,quarantined,grantId} plus separately stored untrusted senderMetadata:{subject,tags} when provided. Sender annotations never become owner tags/classification or privileged rule inputs. readAt:null means unread. Classifications are unclassified, personal, work, spam, and automation; they are organizational labels, not content safety judgments. The shared signed contact endpoint renders a composer on HTML GET, mints a distinct five-minute inbox JWT on the proposed token-MIME GET, and accepts an intentional multipart POST to that same URL. Neither GET sends a message. Authenticated owner routes call list/read/update/delete; owner identity must come from a verified session, never a request field.
packages/messaging/index.mjs exports ContactEndpointService, ContactTokenService, MessagingService, NotificationService, content utilities, and MemoryMessagingStore. The endpoint service receives the grant registry as tokens and an injected signer; MessagingService receives this endpoint service as its tokens verifier. Legacy ContactTokenService remains the internal parent/key registry and compatibility implementation, not the new published contact representation. Other services receive {store,crypto,clock}. Store contract is transaction(async tx => result), with tx.get(key), tx.set(key,value), tx.delete(key), and tx.list(prefix) returning {key,value}[]. Transactions must be serializable, atomic, and roll back on failure. Values include Uint8Array byte content. Plain eventually consistent KV or independently committed statements do not satisfy this contract.
The in-memory adapter serializes operations and is a test/reference implementation. A Durable Object adapter may persist metadata transactionally and externalize immutable bytes to R2. Object writes require bounded staging, unguessable keys scoped to owner/message, metadata commit after successful object storage, and orphan cleanup on failed metadata commits. Never accept arbitrary caller bucket keys. Downloads must verify current owner authorization before resolving an object. Byte accounting, quota reservation, deduplication receipts, metadata, and notification events must commit together; external blob failures must not acknowledge acceptance. Durable-object storage's value-size limit means production adapters must externalize large blobs rather than storing an entire message as one value.
Operations and rules
createCollection(owner,name) returns {id,owner,name,active}. At most 100 collections are accepted. disableCollection revokes associated invitations immediately. receive(token,{collection,mimeType,bytes,idempotencyKey}) binds a receipt to invitation + collection + MIME + SHA-256 of immutable copied bytes. Identical concurrent sends return one acceptance; changed payload under the same key is rejected. Signature, scope, and revocation still apply on replay. Quota consumption is cumulative per invitation and is not refunded when a recipient deletes a message. Account stored-byte/message quotas are refunded on deletion. Defaults cap stored account data at 1 GiB / 10,000 messages.
list(owner,{collection?,unread?,quarantined?,limit?}) returns metadata, newest first, up to 1,000 entries; cursor pagination is not implemented. read(owner,id) returns bytes and metadata without changing unread status. update(owner,id,{read?,collection?,tags?,classification?,quarantined?}) changes only those fields. delete removes message content and its notification event. Moving requires a live destination owned by the same account. Lists and message IDs cannot cross account namespaces.
Rules are an ordered array, at most 100: {id,enabled,match:{grantId?,mimeType?,collection?},actions:{collection?,tags?,classification?,quarantined?}}. Empty match applies to all deliveries. All comparisons use original delivery metadata, preventing accidental rule cascades. Matching actions apply in order; tags replace the current tag array. Quarantine is sticky during ingestion: a later rule cannot undo it. Only explicit owner action releases quarantine. Rules cannot mark an incoming message read, execute code, fetch URLs, trigger remote requests, or expand sender grant scope. A rule targeting a disabled destination is skipped. A grant-wide quarantine rule supports pausing a contact without notifying about its messages. Revoking its invitation blocks acceptance entirely.
Safe rendering and bounded archives
Treat sender bytes and MIME labels as hostile. Plain text requires strict UTF-8 and renders escaped in a pre element. HTML uses a deliberately small inert formatting-tag allowlist. Every tag is reconstructed; every attribute, URL, image, script, style, form, embedded document and custom element is removed. Text is escaped. Rich HTML entities may display literally; exact original styling is not preserved. This is a safe reduced-format viewer, not a general browser or high-fidelity sanitizer.
renderMessage returns either a document with sandbox:"", referrerPolicy:"no-referrer", and CSP, or an attachment descriptor. Document HTML belongs only in an empty-sandbox iframe's srcdoc; never assign sender content or rendered documents to the main application's HTML DOM. CSP blocks all network, scripts, styles, fonts, media, frames, objects, forms, and base URLs. Original bytes are available only as authenticated attachments with nosniff, attachment disposition, and an isolated download origin for production. Rendering unknown types does not execute handlers.
Implemented archive profile: application/vnd.3sln.web-archive+json (project-private, unregistered MIME) containing {version:1,entrypoint:"index.html",entries:[{path,mimeType,data}]}, where data is canonical unpadded base64url. At most 64 entries, 1 MiB per decoded entry, 4 MiB total decoded bytes, 10 MiB envelope; duplicate names, traversal, absolute paths, backslashes, URL syntax, percent escapes, compressed/nested archives and external fetching are rejected. Entry MIME types are text/plain, text/html, image/png, image/jpeg, image/webp; entrypoint must be text. Only the inert entrypoint is displayed; bundled images are not fetched or rendered. The archive is decoded in memory, never extracted into a filesystem. ZIP/WARC/MHTML support and faithful offline page rendering are not implemented.
Automation and realtime evolution
Future structured automation requests or WebRTC signaling can use explicitly authorized exact MIME types and ordinary blob transport. Today these remain opaque attachments. Parsing an offer must never auto-accept a connection or permission prompt: future contracts need schema/size limits, authenticated peer identity distinct from bearer possession, replay/expiry policy, user consent, TURN configuration, IP privacy, session cancellation and abuse controls. Future automation requires narrow independent execution grants. Classifying a blob as automation is not permission to execute it.
Verification and remaining release gates
tests/messaging.test.mjs checks signature/scope enforcement, atomic quota races, idempotency collisions/revocation, cross-account privacy, byte mutation races, collection/key retirement, rule semantics, safe content output, archive traversal, and notification suppression. Production verification additionally requires tests against actual durable metadata and blob storage, orphan recovery, backup/export/deletion, network browser CSP and iframe behavior, DOM fuzzing, accessibility, rate limiting, storage lifecycle, retention, and independent security review. Unit tests do not establish those gates.