Files
unibus/pkg/frame/frame.go
T
egutierrez 22092834bd feat(frame): additive threading — ThreadID, ReplyTo + REACT type
Chat bots need replies, threads and reactions. Add two optional, omitempty
envelope fields (ThreadID, ReplyTo) plus a REACT frame type. The fields ride the
cleartext envelope (message-id references, not secret content) and are omitted
when unset, so non-threaded frames are byte-for-byte identical on the wire and
their signatures unchanged — a non-breaking, additive change.

Client gains PublishReply (threaded reply) and React (emoji reaction). The
reaction content travels in the payload, so it is sealed like any message and
stays confidential in E2E rooms; receivers dispatch on Frame.Type == REACT and
read Frame.ReplyTo for the target. Publish is refactored to share one
publishFrame path with the new helpers; its behavior is unchanged.

Tests: frame round-trip of a threaded REACT frame (golden), non-threaded
wire/sig back-compat asserting thr/re keys are absent (edge), Unmarshal of
garbage errors (error path), and an end-to-end reply+reaction round-trip in an
encrypted ModeMatrix room.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:10:44 +02:00

101 lines
4.0 KiB
Go

// Package frame defines the wire format of the unibus message bus.
//
// A Frame is the unit transported over NATS. It carries an envelope (type,
// subject, sender, message id, epoch) plus an optional payload that, in
// encrypted rooms, is an AEAD ciphertext. Frames may be signed end-to-end with
// Ed25519 so that any receiver can authenticate the sender without trusting the
// transport.
//
// v1 serializes frames as JSON for legibility and forward-compatibility. The
// canonical signing bytes are the JSON of the frame with Sig cleared, so that
// signature verification is independent of field ordering as long as the
// marshaler is deterministic (encoding/json emits struct fields in declaration
// order).
package frame
import (
"crypto/sha256"
"encoding/base64"
"encoding/json"
)
// FrameType enumerates the kinds of frames that travel over the bus.
type FrameType uint8
const (
// PUB is a published message to a room/subject.
PUB FrameType = iota
// INVITE announces a new member was invited (informational; the key
// distribution itself happens over the control plane).
INVITE
// JOIN announces a member joined a room.
JOIN
// LEAVE announces a member voluntarily left a room.
LEAVE
// KICK announces a member was removed from a room.
KICK
// ACK acknowledges receipt of a previous frame.
ACK
// REACT is a reaction to a previous message (an emoji/shortcode). The target
// message id travels in ReplyTo; the reaction content rides Payload, so in
// encrypted rooms the reaction is sealed exactly like any other message.
REACT
)
// BlobRef references an out-of-band encrypted blob stored in the object store.
// The bus never carries blob bytes; only this reference travels over NATS.
type BlobRef struct {
Hash string `json:"h"` // sha256 hex of the ciphertext in the blob store
Nonce []byte `json:"n"` // AEAD nonce used to encrypt the blob
Size int64 `json:"sz"` // size in bytes of the ciphertext
}
// Frame is the unit of transport on the unibus message bus.
//
// Threading metadata (ThreadID, ReplyTo) is additive and optional: it travels in
// the cleartext envelope (these are message-id references, not secret content)
// and is omitted entirely when unset, so the wire format and signatures of
// non-threaded frames are byte-for-byte identical to before this field existed.
type Frame struct {
Type FrameType `json:"t"`
Subject string `json:"s"`
Sender string `json:"from"` // endpoint id = EndpointID(signPub)
MsgID string `json:"id"` // ULID
Epoch int `json:"e"` // epoch of the room key K used to encrypt
ThreadID string `json:"thr,omitempty"` // root message id of the thread this frame belongs to
ReplyTo string `json:"re,omitempty"` // message id this frame replies to / reacts to
Nonce []byte `json:"n,omitempty"` // AEAD nonce (encrypted rooms only)
Payload []byte `json:"p,omitempty"` // AEAD ciphertext (or cleartext if the room does not encrypt)
Blob *BlobRef `json:"b,omitempty"`
Sig []byte `json:"sig,omitempty"` // Ed25519 signature over SigningBytes()
}
// Marshal serializes the frame to its wire representation (JSON in v1).
func (f Frame) Marshal() ([]byte, error) {
return json.Marshal(f)
}
// Unmarshal parses a wire representation back into a Frame.
func Unmarshal(b []byte) (Frame, error) {
var f Frame
err := json.Unmarshal(b, &f)
return f, err
}
// EndpointID derives a stable, transport-agnostic endpoint identifier from an
// Ed25519 signing public key: base64url(sha256(signPub)), unpadded.
func EndpointID(signPub []byte) string {
sum := sha256.Sum256(signPub)
return base64.RawURLEncoding.EncodeToString(sum[:])
}
// SigningBytes returns the canonical bytes that are signed and verified. The
// signature covers the entire frame except the Sig field itself, so we clear
// Sig before marshaling. Errors are intentionally swallowed: the frame is a
// plain struct of JSON-serializable fields, so json.Marshal cannot fail here.
func (f Frame) SigningBytes() []byte {
f.Sig = nil
b, _ := json.Marshal(f)
return b
}