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
- func DisplayURL(s string) string
- type APIError
- type AddContactRequest
- type AvatarUploaded
- type BroadcastInfo
- type Client
- func (c *Client) AddContact(ctx context.Context, peerURL string) (Contact, error)
- func (c *Client) BaseURL() string
- func (c *Client) DeleteAvatar(ctx context.Context) error
- func (c *Client) DeleteContact(ctx context.Context, peerURL string) error
- func (c *Client) GetIdentity(ctx context.Context) (Identity, error)
- func (c *Client) HTTPClient() *http.Client
- func (c *Client) ListContacts(ctx context.Context) ([]Contact, error)
- func (c *Client) ListMessages(ctx context.Context, peerURL string, cur MessageCursor) ([]Message, error)
- func (c *Client) MarkRead(ctx context.Context, peerURL string, rowID int64) (MarkReadResponse, error)
- func (c *Client) PatchIdentity(ctx context.Context, p IdentityPatch) (Identity, error)
- func (c *Client) PutAvatar(ctx context.Context, contentType string, body []byte) (AvatarUploaded, error)
- func (c *Client) RetryMessage(ctx context.Context, rowID int64) (SendAccepted, error)
- func (c *Client) Search(ctx context.Context, query, peerFilter string, limit int) ([]Message, error)
- func (c *Client) SendMessage(ctx context.Context, req SendRequest) (SendAccepted, error)
- func (c *Client) Token() string
- func (c *Client) UploadFile(ctx context.Context, contentType string, body []byte) (Uploaded, error)
- type ConnectedEvent
- type Contact
- type ContactChangedEvent
- type ContactRemovedEvent
- type Direction
- type DisconnectedEvent
- type Event
- type EventStream
- type FatalEvent
- type Identity
- type IdentityChangedEvent
- type IdentityPatch
- type InboundEvent
- type MarkReadRequest
- type MarkReadResponse
- type Message
- type MessageCursor
- type OutboundStateData
- type OutboundStateEvent
- type OutboundStatus
- type ReadWatermarkChangedData
- type ReadWatermarkChangedEvent
- type ReadyData
- type ReadyEvent
- type ResyncEvent
- type SendAccepted
- type SendRequest
- type Uploaded
- type Verifier
- type VerifyStatus
Constants ¶
const ( KindRoomV1 = "posta.room/v1" RoomActionBroadcast = "broadcast" )
Broadcast payload kinds + actions per SPEC §14.
Variables ¶
This section is empty.
Functions ¶
func DisplayURL ¶
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.
type AddContactRequest ¶
type AddContactRequest struct {
URL string `json:"url"`
}
AddContactRequest is the body of POST /api/v1/contacts.
type AvatarUploaded ¶
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 ¶
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 ¶
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 (*Client) DeleteAvatar ¶
DeleteAvatar clears the identity's avatar. Idempotent — returns no error whether or not an avatar was set.
func (*Client) DeleteContact ¶
func (*Client) HTTPClient ¶
HTTPClient exposes the underlying http.Client so the SSE layer can share connection pooling and timeouts.
func (*Client) ListMessages ¶
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 (*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 (*Client) SendMessage ¶
func (c *Client) SendMessage(ctx context.Context, req SendRequest) (SendAccepted, error)
func (*Client) UploadFile ¶
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.
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 ¶
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 ¶
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 ¶
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 ¶
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