relay

package module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Jul 3, 2026 License: MIT Imports: 21 Imported by: 0

README

hive-relay

A small, content-blind rendezvous + envelope-forwarding server for Hive. Clients post opaque (end-to-end encrypted) event envelopes keyed by workspace and fetch everything after a cursor; the relay also brokers short pairing codes, an identity directory, a poll-based account inbox, the friend graph, and presence. It never sees plaintext.

This is the production relay (Go). It speaks the same JSON /v1 contract and hrt1 entitlement-token format as the original Rust reference relay (crates/hive-relay), which is now kept only as an in-process test fixture for the Rust client.

Run

go run ./cmd/hive-relay            # serves on :8443 (in-memory)
# or build:
go build -o hive-relay ./cmd/hive-relay && ./hive-relay

Operator commands (mint/verify signed entitlement tokens — the serving relay only ever verifies):

hive-relay keygen                  # prints an Ed25519 issuer keypair
hive-relay issue --key <priv-hex> --sub <id> --plan team --exp-days 365 \
  --max-members 50 --turn --cap remove_member

Configuration (env)

Var Meaning
PORT / HIVE_RELAY_ADDR bind address ($PORT wins; default 0.0.0.0:8443)
DATABASE_URL shared Postgres store → horizontal scaling / HA (no data migration)
HIVE_RELAY_DATA_DIR in-memory store + JSON snapshot here (single instance). Ignored if DATABASE_URL is set
HIVE_RELAY_TOKEN_PUBKEY Ed25519 public key → require signed entitlement tokens
HIVE_RELAY_ACCESS_TOKENS comma-separated static allowlist (coarse gate)
HIVE_RELAY_FRIEND_CAP max accepted friends per account

Storage selection: DATABASE_URL → Postgres; else HIVE_RELAY_DATA_DIR → memory+snapshot; else in-memory only.

Test

go test ./...                                  # unit + HTTP + snapshot + seams
TEST_DATABASE_URL=postgres://… go test ./...   # also runs Postgres integration

Deploy

The image is a tiny static binary on Alpine:

docker build -t hive-relay .          # then run with the env above

deploy/fly.toml is a ready Fly.io config (fly launch --copy-config). Put TLS in front (Fly's edge, or your own LB) so clients get an https:// URL, and either mount a volume at /data (snapshot store) or set DATABASE_URL (shared Postgres → scale out).

To gate a self-hosted relay, set HIVE_RELAY_TOKEN_PUBKEY and mint tokens with hive-relay keygen / hive-relay issue — keep the issuer private key off the relay host (the relay only ever verifies).

Extending (seams)

This package is a complete relay on its own. It also exposes extension points (see seams.go) so a downstream build can add custom behavior via New(Options{...}) without forking:

  • Store — durable backend (in-memory/snapshot or Postgres built in).
  • EntitlementVerifier — admission policy (open / allowlist / signed from env, or your own).
  • WriteGuard — optional pre-write authorization hook (nil = content-blind).
  • Hooks — optional lifecycle observers (e.g. audit / accounting; no-op by default).

License

MIT — see LICENSE.

Documentation

Overview

Package main — the Hive relay: a content-blind rendezvous + envelope-forwarding server. Clients post opaque (end-to-end encrypted) event envelopes keyed by workspace and fetch everything after a cursor; the relay also brokers short pairing codes, an identity directory, a poll-based account inbox, the friend graph, and presence. It never sees plaintext.

This is a Go port of the reference Rust relay (crates/hive-relay), preserving the JSON /v1 wire contract and the hrt1 entitlement-token format byte-for-byte so existing clients and issued tokens keep working.

Index

Constants

This section is empty.

Variables

View Source
var (
	ErrSelfRequest    = errors.New("cannot friend yourself")
	ErrAlreadyFriends = errors.New("already friends")
	ErrCapReached     = errors.New("friend cap reached")
	ErrNotFound       = errors.New("request not found")
	ErrNotYours       = errors.New("not your request")
	ErrNotPending     = errors.New("request not pending")
	ErrTooManyPending = errors.New("too many pending outbound requests")
)

Functions

func Main

func Main()

Types

type DeviceRow

type DeviceRow struct {
	DeviceID string  `json:"deviceId"`
	NodeID   *string `json:"nodeId"`
	Label    *string `json:"label"`
	LastSeen int64   `json:"lastSeen"`
}

DeviceRow is a registered device as returned to callers.

type DirAccount

type DirAccount struct {
	GitHubID uint64
	Login    string
	Name     *string
	Devices  map[string]string // device id → ka public key
}

DirAccount is a directory entry: a verified GitHub identity + each device's X25519 key-agreement public key (opaque to the relay), so a teammate can be invited by @handle and the workspace key sealed to all their devices.

type EntitlementVerifier

type EntitlementVerifier interface {
	Allow(token string, nowUnix int64) (*TokenClaims, bool)
}

EntitlementVerifier decides whether a presented bearer token is admitted at nowUnix. The returned claims (non-nil for signed tokens) let downstream enforcement read per-plan limits + RBAC capabilities.

func EntitlementFromEnv

func EntitlementFromEnv() EntitlementVerifier

EntitlementFromEnv is the exported, interface-typed constructor the OSS binary and downstream builds use to get the default env-driven verifier.

type Envelope

type Envelope struct {
	Seq  uint64          `json:"seq"`
	Body json.RawMessage `json:"body"`
}

Envelope is one stored workspace event: a monotonic server sequence + the opaque body verbatim.

type Friend

type Friend struct {
	AccountKey string
	Login      string
}

Friend is an accepted friend (account key + login).

type FriendPresence

type FriendPresence struct {
	AccountKey string
	Login      string
	Presence   string
}

FriendPresence is a friend plus their presence state.

type FriendRequest

type FriendRequest struct {
	ID          string
	FromAccount string
	FromLogin   string
	ToAccount   string
	ToLogin     string
	CreatedAt   int64
	State       RequestState
}

FriendRequest is a pending/closed friend request. FromAccount/ToAccount are account keys (github:<id>); the relay stamps From from the verified GitHub token, so a request can't be forged to look like another user.

type Hooks

type Hooks interface {
	// WorkspaceWritten fires after a durable workspace write (envelope or key
	// rotation), carrying the assigned server sequence — the natural metering
	// + audit point.
	WorkspaceWritten(ctx context.Context, workspace string, seq uint64, claims *TokenClaims)
}

Hooks observe successful operations for audit + usage metering. All methods must tolerate a nil claims (open / token-allowlist policies carry none). The open relay leaves this nil (no-op).

type InboxRow

type InboxRow struct {
	Seq  uint64          `json:"seq"`
	Body json.RawMessage `json:"body"`
}

InboxRow is one account-channel event (same shape as Envelope on the wire).

type Options

type Options struct {
	Store       Store               // required
	Entitlement EntitlementVerifier // nil → EntitlementFromEnv()
	WriteGuard  WriteGuard          // nil → allow all writes (content-blind)
	Hooks       Hooks               // nil → no-op
	FriendCap   *int                // nil → unlimited
}

Options configure a Server. Only Store is required; the rest default to the open-relay behavior. Downstream builds may inject implementations of the seams (see seams.go) here without touching this package.

type RequestState

type RequestState string

RequestState is the lifecycle of a friend request.

const (
	StatePending   RequestState = "pending"
	StateAccepted  RequestState = "accepted"
	StateRejected  RequestState = "rejected"
	StateCancelled RequestState = "cancelled"
)

type Server

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

Server holds the durable Store plus the ephemeral, instance-local pieces (short pairing codes), the entitlement verifier, and the optional seams.

func New

func New(o Options) *Server

New builds a Server from Options, filling in open-relay defaults.

func (*Server) Handler

func (s *Server) Handler() http.Handler

Handler builds the /v1 router. Everything except /v1/health sits behind the entitlement gate (a no-op when the policy is Open, i.e. self-hosted).

type Store

type Store interface {
	// Workspace sync (opaque envelopes + rendezvous + key rotations).
	AppendEnvelope(ctx context.Context, workspace string, body json.RawMessage) (uint64, error)
	EnvelopesAfter(ctx context.Context, workspace string, after uint64) ([]Envelope, error)
	PutCandidate(ctx context.Context, workspace, deviceID string, candidate json.RawMessage) error
	Candidates(ctx context.Context, workspace string) (map[string]json.RawMessage, error)
	PutPresence(ctx context.Context, workspace, deviceID string, presence json.RawMessage) error
	PresenceBlobs(ctx context.Context, workspace string) (map[string]json.RawMessage, error)
	AppendKeyRotation(ctx context.Context, workspace string, blob json.RawMessage) error
	KeyRotations(ctx context.Context, workspace string) ([]json.RawMessage, error)

	// Identity directory (invite-by-handle, seal-to-all-devices).
	UpsertDirDevice(ctx context.Context, githubID uint64, login string, name *string, deviceID, kaPub string) error
	DirAccountByHandle(ctx context.Context, handle string) (*DirAccount, error)

	// Account registry + per-account inbox (the social channel).
	RegisterAccountDevice(ctx context.Context, githubID uint64, login, deviceID string, nodeID, label *string, now int64) (string, error)
	HeartbeatDevice(ctx context.Context, githubID uint64, deviceID string, now int64) (bool, error)
	PushAccountEvent(ctx context.Context, accountKey string, body json.RawMessage) (uint64, error)
	AccountInboxAfter(ctx context.Context, accountKey string, after uint64) ([]InboxRow, error)
	AccountDevices(ctx context.Context, accountKey string) ([]DeviceRow, error)
	AccountKeyForLogin(ctx context.Context, login string) (string, bool, error)
	SetVisibility(ctx context.Context, githubID uint64, appearOffline bool) error
	PresenceOf(ctx context.Context, accountKey string, now int64) (string, error)

	// Friend graph.
	AreFriends(ctx context.Context, a, b string) (bool, error)
	CreateFriendRequest(ctx context.Context, fromAccount, fromLogin, toAccount, toLogin string, now int64, friendCap *int) (FriendRequest, error)
	AcceptFriendRequest(ctx context.Context, requestID, acceptor string, friendCap *int) (FriendRequest, error)
	CloseFriendRequest(ctx context.Context, requestID, actor string) (FriendRequest, error)
	RemoveFriend(ctx context.Context, account, other string) (bool, error)
	ListFriends(ctx context.Context, account string) ([]Friend, error)
	IncomingRequests(ctx context.Context, account string, now int64) ([]FriendRequest, error)
	// FriendDevices returns a friend's devices; the bool is false (→ 403) when
	// caller and friend are not accepted friends.
	FriendDevices(ctx context.Context, caller, friend string) ([]DeviceRow, bool, error)
	FriendCount(ctx context.Context, account string) (int, error)
	FriendPresence(ctx context.Context, account string, now int64) ([]FriendPresence, error)

	// Durability (snapshot-backed stores only; no-op otherwise).
	Flush(ctx context.Context) error
	PersistenceEnabled() bool
	Close() error
}

── Store: the durable-state seam ────────────────────────────────────────────

Every piece of state that must survive restarts and (for HA) be shared across instances goes through this interface, so the backend is a deployment choice with no future data migration:

  • memoryStore — in-process maps + an optional JSON snapshot (self-host default, zero deps, what tests run against).
  • postgresStore (Phase 2) — a shared SQL store selected via DATABASE_URL, so running multiple relay instances works with no data migration.

Ephemeral, instance-local state (short pairing codes) stays out of here — see the Server. friendCap is threaded through the friend-graph methods so the store stays free of policy.

func NewMemoryStore

func NewMemoryStore() Store

NewMemoryStore returns an in-process, ephemeral Store (no persistence).

func NewMemoryStoreWithPersistence

func NewMemoryStoreWithPersistence(dataDir string) Store

NewMemoryStoreWithPersistence returns an in-process Store backed by a JSON snapshot under dataDir (loaded on boot, flushed periodically).

func NewPostgresStore

func NewPostgresStore(ctx context.Context, dsn string) (Store, error)

NewPostgresStore returns a Store backed by a shared Postgres (DSN), running idempotent migrations on connect. Use for horizontal scaling / HA.

type TokenClaims

type TokenClaims struct {
	Sub           string   `json:"sub"`
	Plan          string   `json:"plan"`
	Exp           uint64   `json:"exp"`
	MaxMembers    *uint32  `json:"max_members"`
	RetentionDays *uint32  `json:"retention_days"`
	Turn          bool     `json:"turn"`
	Caps          []string `json:"caps"`
}

TokenClaims are carried by a signed entitlement token. Forward-compatible: unknown fields are ignored on decode, and the relay ignores any capability it does not yet enforce. Field names match the Rust issuer's serde output.

func (TokenClaims) HasCap

func (c TokenClaims) HasCap(cap string) bool

HasCap reports whether the subject holds a named RBAC capability.

func (TokenClaims) IsExpired

func (c TokenClaims) IsExpired(nowUnix int64) bool

IsExpired reports whether Exp is set and now-or-past.

type WriteGuard

type WriteGuard interface {
	CheckWrite(ctx context.Context, workspace string, claims *TokenClaims, r *http.Request) error
}

WriteGuard runs before any workspace write (envelopes / keyring / candidates / presence). Return a non-nil error to reject the write (mapped to 403). Default nil = pure content-blind forwarding. A downstream build can set one to enforce workspace membership / roles from the verified claims.

Directories

Path Synopsis
cmd
hive-relay command
Command hive-relay is the open-source Hive relay binary: a content-blind rendezvous + envelope-forwarding server.
Command hive-relay is the open-source Hive relay binary: a content-blind rendezvous + envelope-forwarding server.

Jump to

Keyboard shortcuts

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