api

package
v0.0.0-...-204b530 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: May 14, 2026 License: MIT Imports: 13 Imported by: 0

Documentation

Overview

Package api defines the wire types and HTTP+SSE client for the posta-server v1 client API (see ../../../server/CLIENT_API.md).

JSON field names mirror the contract verbatim (camelCase). Timestamps are RFC3339 strings — kept as strings here rather than time.Time so that empty values (which the contract uses for "unset") round-trip without a parse.

Index

Constants

View Source
const (
	KindRoomV1          = "posta.room/v1"
	RoomActionBroadcast = "broadcast"
)

Broadcast payload kinds + actions per SPEC §14.

Variables

This section is empty.

Functions

func DisplayURL

func DisplayURL(s string) string

DisplayURL is the §4.2 display form of a §4.1 canonical URL: the scheme stripped, host+port+path intact. The server is inconsistent today — `GET /contacts` and the API-handler-triggered SSE events (createContact, deleteContact, markRead) return display form, while the daemon-runner- emitted SSE events (inbound, outbound-state, contact-changed from the outbox/inbox path) emit canonical form. We normalize everything to display form on ingress so the state's map keys and lookups stay consistent regardless of which emit site sent the event. Idempotent — already-display strings pass through unchanged. Surfaced as a server gap; remove this layer once runner.go runs the same urlinput.Display() the API handlers do.

Types

type APIError

type APIError struct {
	StatusCode int    `json:"-"`
	Code       string `json:"error"`
	Message    string `json:"message"`
}

APIError is the shape of every non-2xx body.

func (*APIError) Error

func (e *APIError) Error() string

type AddContactRequest

type AddContactRequest struct {
	URL string `json:"url"`
}

AddContactRequest is the body of POST /api/v1/contacts.

type AvatarUploaded

type AvatarUploaded struct {
	URL  string `json:"url"`
	Hash string `json:"hash"`
}

AvatarUploaded is the 200 response from PUT /api/v1/identity/avatar. The same shape is reused for any future content-addressed image responses.

type BroadcastInfo

type BroadcastInfo struct {
	InnerEnvelope *posta.Envelope
	RawInnerBytes []byte
	Signature     []byte
}

BroadcastInfo is the parsed result of unwrapping a posta.room/v1 broadcast wrapper. RawInnerBytes is the exact byte sequence the original sender signed — feeding it to posta.VerifySignature with the inner sender's published key proves the inner envelope's origin per SPEC §14.2.

The fields are deliberately split: InnerEnvelope is the parsed view (used for rendering and threading), RawInnerBytes is the wire bytes (used for signature verification). Never re-serialize the parsed envelope and verify against that — see SPEC §14.3 for why.

func UnwrapBroadcast

func UnwrapBroadcast(payload []byte) (*BroadcastInfo, bool, error)

UnwrapBroadcast checks whether payload is a posta.room/v1 action=broadcast wrapper and, if so, parses the inner envelope and signature.

Returns:

  • (info, true, nil) on a well-formed broadcast wrapper
  • (nil, false, nil) if the payload is not a broadcast (different kind or different action) — caller renders normally
  • (nil, true, err) if the payload claims to be a broadcast but is malformed — caller surfaces the error

Mismatch on kind/action is NOT an error: under SPEC §13.1.3 unknown kinds flow through, and posta.room/v1 can carry future actions other than "broadcast" (see SPEC §14.6 for "leave"). The bool lets the caller distinguish "skip, not a broadcast" from "broadcast, but corrupt".

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client is a bearer-authenticated REST client for the posta-server v1 API. Construct with New; every method takes a Context and returns either a decoded response or an *APIError describing the server's machine-readable failure.

func New

func New(baseURL, token string, hc *http.Client) *Client

New builds a Client. baseURL is the server origin (e.g. "https://arne.posta.no"); the v1 path is appended automatically. token is the plaintext bearer (mst_…). hc may be nil to use a sensible default.

func (*Client) AddContact

func (c *Client) AddContact(ctx context.Context, peerURL string) (Contact, error)

func (*Client) BaseURL

func (c *Client) BaseURL() string

BaseURL returns the configured server origin. SSE wiring needs it too.

func (*Client) DeleteAvatar

func (c *Client) DeleteAvatar(ctx context.Context) error

DeleteAvatar clears the identity's avatar. Idempotent — returns no error whether or not an avatar was set.

func (*Client) DeleteContact

func (c *Client) DeleteContact(ctx context.Context, peerURL string) error

func (*Client) GetIdentity

func (c *Client) GetIdentity(ctx context.Context) (Identity, error)

func (*Client) HTTPClient

func (c *Client) HTTPClient() *http.Client

HTTPClient exposes the underlying http.Client so the SSE layer can share connection pooling and timeouts.

func (*Client) ListContacts

func (c *Client) ListContacts(ctx context.Context) ([]Contact, error)

func (*Client) ListMessages

func (c *Client) ListMessages(ctx context.Context, peerURL string, cur MessageCursor) ([]Message, error)

func (*Client) MarkRead

func (c *Client) MarkRead(ctx context.Context, peerURL string, rowID int64) (MarkReadResponse, error)

MarkRead advances the per-peer read watermark to rowID. Returns the server-confirmed (peer, rowId) on advance, or a zero-valued response with nil error when the request was at-or-behind the stored watermark (server replied 204). 404 is surfaced as an *APIError so the caller can distinguish "peer not in contacts" from a benign no-op.

func (*Client) PatchIdentity

func (c *Client) PatchIdentity(ctx context.Context, p IdentityPatch) (Identity, error)

func (*Client) PutAvatar

func (c *Client) PutAvatar(ctx context.Context, contentType string, body []byte) (AvatarUploaded, error)

PutAvatar uploads avatar bytes. contentType must be "image/png" or "image/jpeg"; the server sniffs magic bytes and rejects mismatches. The 1 MiB cap is enforced server-side (413 payload-too-large) and locally to avoid wasting the round-trip.

func (*Client) RetryMessage

func (c *Client) RetryMessage(ctx context.Context, rowID int64) (SendAccepted, error)

func (*Client) Search

func (c *Client) Search(ctx context.Context, query, peerFilter string, limit int) ([]Message, error)

func (*Client) SendMessage

func (c *Client) SendMessage(ctx context.Context, req SendRequest) (SendAccepted, error)

func (*Client) Token

func (c *Client) Token() string

Token returns the configured bearer token. SSE wiring needs it too.

func (*Client) UploadFile

func (c *Client) UploadFile(ctx context.Context, contentType string, body []byte) (Uploaded, error)

UploadFile posts attachment bytes. contentType must be "image/png" or "image/jpeg" (server sniffs magic bytes); cap is 10 MiB. The response's content-addressed URL is what senders echo into a posta.link/v1 payload.

type ConnectedEvent

type ConnectedEvent struct{}

ConnectedEvent fires after a successful (re)connect — useful for the UI to clear any "reconnecting…" indicator.

type Contact

type Contact struct {
	URL           string `json:"url"`
	Name          string `json:"name"`
	LastMessageAt string `json:"lastMessageAt"`
	LastReadRowID int64  `json:"lastReadRowId"`
}

Contact is one peer ordered by most recent activity.

LastReadRowID is the highest messages.rowId the user has acknowledged for this peer (0 if nothing has been marked read). Unread = inbound messages in this thread with rowId > LastReadRowID. The server emits a separate `read-watermark-changed` SSE event when this advances; the contact-changed event does NOT carry the field, so reducers must preserve the local value across contact-changed updates.

type ContactChangedEvent

type ContactChangedEvent struct{ Contact Contact }

ContactChangedEvent fires when a contact row is added, renamed, or has its last-activity bumped. The full Contact row is included.

type ContactRemovedEvent

type ContactRemovedEvent struct{ URL string }

ContactRemovedEvent fires when a contact is deleted (this client or any sibling client called DELETE /api/v1/contacts). Message history is preserved server-side; a future inbound from the same peer will re-create the row and emit ContactChangedEvent.

type Direction

type Direction string

Direction is "in" for received messages and "out" for sent ones.

const (
	DirectionIn  Direction = "in"
	DirectionOut Direction = "out"
)

type DisconnectedEvent

type DisconnectedEvent struct{ Err error }

DisconnectedEvent fires when the SSE connection drops. The stream is reconnecting in the background; this event is informational for the UI. Err may be nil for a clean EOF.

type Event

type Event interface {
	// contains filtered or unexported methods
}

Event is the sum type emitted by EventStream.Recv. Every concrete event implements isEvent so callers can route via a type switch — the same shape the TUI's bubbletea Update loop wants.

type EventStream

type EventStream struct {
	// contains filtered or unexported fields
}

EventStream is a long-lived consumer of GET /api/v1/events. Construct with NewEventStream, then call Run in a goroutine and Recv from the consumer side. The stream owns its reconnect loop and Last-Event-ID bookkeeping.

func NewEventStream

func NewEventStream(c *Client, buffer int) *EventStream

NewEventStream wires a stream against c. Buffer is the channel depth; 16 is a reasonable default — the SSE producer will block on backpressure rather than drop, which is the right tradeoff for a UI that must stay consistent.

func (*EventStream) LastID

func (s *EventStream) LastID() string

LastID returns the most recent SSE id observed. Useful for persisting a resume cursor at shutdown.

func (*EventStream) Recv

func (s *EventStream) Recv() <-chan Event

Recv returns the receive end of the event channel.

func (*EventStream) Run

func (s *EventStream) Run(ctx context.Context)

Run blocks until ctx is cancelled or a fatal error occurs. It owns the reconnect loop: on transient failures it backs off exponentially (capped at maxBackoff) and resumes from lastID; on 401 it emits FatalEvent and returns.

func (*EventStream) SetResumeID

func (s *EventStream) SetResumeID(id string)

SetResumeID seeds the Last-Event-ID for the first connection. Use this when the caller persisted a cursor across process restarts.

type FatalEvent

type FatalEvent struct{ Err error }

FatalEvent fires when the stream gives up — currently only on 401, which indicates a bad/revoked token and is not worth retrying.

type Identity

type Identity struct {
	URL       string `json:"url"`
	Name      string `json:"name"`
	About     string `json:"about"`
	Avatar    string `json:"avatar"`
	UpdatedAt string `json:"updatedAt"`
}

Identity is the local owner identity served on the wire as the actor doc.

type IdentityChangedEvent

type IdentityChangedEvent struct{ Identity Identity }

IdentityChangedEvent fires when a different connected client edited the local identity via PATCH /identity.

type IdentityPatch

type IdentityPatch struct {
	Name  *string `json:"name,omitempty"`
	About *string `json:"about,omitempty"`
}

IdentityPatch is the body of PATCH /api/v1/identity. Omitted fields are unchanged on the server; an explicit empty string clears the field. Avatar is intentionally NOT a field here — the server rejects PATCH bodies that carry an `avatar` key with 400 bad-request. Use PutAvatar/DeleteAvatar.

type InboundEvent

type InboundEvent struct{ Message Message }

InboundEvent carries a freshly-stored inbound message. The DTO is the same shape as a row from GET /messages, including a populated RowID, so the receiver can index it directly without a follow-up fetch.

type MarkReadRequest

type MarkReadRequest struct {
	Peer  string `json:"peer"`
	RowID int64  `json:"rowId"`
}

MarkReadRequest is the body of POST /api/v1/contacts/read. Used to advance the per-peer read watermark so unread counts sync across a user's devices.

type MarkReadResponse

type MarkReadResponse struct {
	Peer  string `json:"peer"`
	RowID int64  `json:"rowId"`
}

MarkReadResponse is the 200 body when the watermark advanced. The endpoint returns 204 with no body when the request was at-or-behind the stored watermark, so callers should treat a zero RowID as "no advance".

type Message

type Message struct {
	RowID     int64           `json:"rowId,omitempty"`
	Direction Direction       `json:"direction"`
	MsgID     string          `json:"msgId"`
	Peer      string          `json:"peer"`
	Timestamp string          `json:"timestamp"`
	CreatedAt string          `json:"createdAt"`
	Payload   json.RawMessage `json:"payload"`
	InReplyTo string          `json:"inReplyTo,omitempty"`
	Status    OutboundStatus  `json:"status,omitempty"`
}

Message is one row in the inbox/outbox.

`RowID` is present everywhere — including the body of an `inbound` SSE event, which now carries the full DTO. `Status` is empty for inbound rows.

type MessageCursor

type MessageCursor struct {
	// Before, when non-zero, returns rows older than this rowId in DESC order.
	Before int64
	// Since, when non-zero, returns rows newer than this rowId in ASC order
	// (used for SSE backfill on resync). Mutually exclusive with Before.
	Since int64
	// Limit caps the result set; 0 means "use server default" (50, max 200).
	Limit int
}

MessageCursor selects the paging mode for ListMessages. Zero value means "no cursor — newest first, limit applies".

type OutboundStateData

type OutboundStateData struct {
	RowID         int64          `json:"rowId"`
	MsgID         string         `json:"msgId,omitempty"`
	Peer          string         `json:"peer,omitempty"`
	Status        OutboundStatus `json:"status"`
	Attempts      int            `json:"attempts"`
	LastErrorCode string         `json:"lastErrorCode,omitempty"`
}

OutboundStateData is the payload of an `outbound-state` event. The contract shows rowId/status/attempts; msgId/peer are observed in the example but not formally required, so they're tolerated as optional here. LastErrorCode is included on failed-* transitions so the UI can surface the reason.

type OutboundStateEvent

type OutboundStateEvent struct{ Data OutboundStateData }

OutboundStateEvent is an outbox row state transition.

type OutboundStatus

type OutboundStatus string

OutboundStatus is the lifecycle of an outbound row. Empty for inbound.

const (
	StatusPending          OutboundStatus = "pending"
	StatusSending          OutboundStatus = "sending"
	StatusDelivered        OutboundStatus = "delivered"
	StatusFailedPermanent  OutboundStatus = "failed-permanent"
	StatusFailedPendingUsr OutboundStatus = "failed-pending-user"
)

type ReadWatermarkChangedData

type ReadWatermarkChangedData struct {
	Peer  string `json:"peer"`
	RowID int64  `json:"rowId"`
}

ReadWatermarkChangedData is the payload of a `read-watermark-changed` event. The watermark is monotonic on the server; clients mirror that by applying `local = max(local, event.rowId)`, since events may arrive out of order after a reconnect or under concurrent advances.

type ReadWatermarkChangedEvent

type ReadWatermarkChangedEvent struct{ Data ReadWatermarkChangedData }

ReadWatermarkChangedEvent fires when another connected client advanced the per-peer read watermark via POST /contacts/read. Apply as `local = max(local, event.RowID)` and recompute the peer's unread count.

type ReadyData

type ReadyData struct {
	CurrentID int64 `json:"currentId"`
}

ReadyData is the payload of the first event on every SSE connection. `CurrentID` is the watermark for any REST fetches done during handshake.

type ReadyEvent

type ReadyEvent struct{ CurrentID int64 }

ReadyEvent is the first event on every connection. CurrentID is the watermark for any REST fetches done as part of the handshake.

type ResyncEvent

type ResyncEvent struct{}

ResyncEvent fires when the server's ring buffer has lapped our Last-Event-ID. Caller must backfill via GET /messages?since=<lastKnownRowId> and treat live events as authoritative from this point.

type SendAccepted

type SendAccepted struct {
	RowID    int64          `json:"rowId"`
	MsgID    string         `json:"msgId"`
	Peer     string         `json:"peer"`
	Status   OutboundStatus `json:"status"`
	Attempts int            `json:"attempts"`
}

SendAccepted is the 202 response from POST /api/v1/messages.

type SendRequest

type SendRequest struct {
	Peer           string          `json:"peer"`
	Payload        json.RawMessage `json:"payload"`
	InReplyTo      string          `json:"inReplyTo,omitempty"`
	IdempotencyKey string          `json:"-"`
}

SendRequest is the body of POST /api/v1/messages.

IdempotencyKey is not part of the wire body — when non-empty the client sets it as the `Idempotency-Key` HTTP header (see CLIENT_API.md "Idempotency"). Mobile clients should send a UUID per logical send so a background-mid-send retry returns the same row instead of double-posting; the TUI doesn't need it but the field is here so iOS can mirror the type.

type Uploaded

type Uploaded struct {
	URL       string `json:"url"`
	MediaType string `json:"mediaType"`
	Size      int64  `json:"size"`
	Hash      string `json:"hash"`
}

Uploaded is the 201 response from POST /api/v1/uploads — the server-side content-addressed URL the sender then echoes into a `posta.link/v1` message payload.

type Verifier

type Verifier struct {
	// contains filtered or unexported fields
}

Verifier resolves inner-sender keys and verifies inner-envelope signatures. One Verifier per TUI process is enough: the underlying ActorCache is goroutine-safe and the §5.3 TTL collapses repeated lookups within a few minutes into a single HTTPS GET.

func NewVerifier

func NewVerifier(hc *http.Client) *Verifier

NewVerifier wires a Verifier with an HTTPS fetcher. The hc is the http.Client used for actor-doc fetches; if nil, http.DefaultClient is used. Note: this client is NOT bearer-authenticated — the actor doc is public per SPEC §5.1, and credentials don't belong on these fetches.

func (*Verifier) Verify

func (v *Verifier) Verify(ctx context.Context, info *BroadcastInfo) error

Verify executes SPEC §14.2 inner verification: resolve the inner sender's public key by KeyID via the actor doc, then verify the Ed25519 signature over the raw inner envelope bytes. Returns nil on success; an error names the failure category (unknown key, malformed doc, signature mismatch, network error).

Per SPEC §14.2: only §7.3 + §7.4 are applied to the inner envelope. Recipient match (§7.2) and timestamp skew (§7.5) are DELIBERATELY not checked here — the inner recipient is the room URL, and rooms may legitimately deliver outside the inner timestamp window.

type VerifyStatus

type VerifyStatus int

VerifyStatus is the outcome of inner-signature verification per SPEC §14.2. Pending is the initial value: verification is async (one HTTPS GET to the inner sender's actor doc, plus an Ed25519 verify). OK and Failed are terminal.

const (
	VerifyPending VerifyStatus = iota
	VerifyOK
	VerifyFailed
)

func (VerifyStatus) String

func (s VerifyStatus) String() string

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL