Help us shape the future of Go! Take the 2026 Go Developer Survey

apnsrelay

package
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Aug 13, 2026 License: Apache-2.0 Imports: 19 Imported by: 0

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func DefaultClientFactory

func DefaultClientFactory(keyID, teamID, bundleID string, env apns.Env, key *ecdsa.PrivateKey) *apns.Client

DefaultClientFactory is apns.NewClient.

Types

type ClientCache

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

ClientCache owns the shared APNs provider clients. The relay holds ONE client per publisher identity tuple (in practice one, since the relay has a single .p8); all queue workers send through the cached instance so APNs sees a stable provider JWT instead of per-send JWT rotations that trip 429 TooManyProviderTokenUpdates.

func NewClientCache

func NewClientCache(f ClientFactory) *ClientCache

NewClientCache builds a cache with the given factory (DefaultClientFactory for production, a counting stub for the regression test).

func (*ClientCache) Client

func (c *ClientCache) Client(keyID, teamID, bundleID string, env apns.Env, key *ecdsa.PrivateKey) *apns.Client

Client returns the cached *apns.Client for the tuple, building it on the first call. Concurrent callers for the same tuple all receive the same instance — the factory runs exactly once per tuple under contention (see the regression test). A tuple change (e.g. a new keyID after .p8 rotation) yields a fresh client; call Invalidate to drop the old one.

keyID/teamID are the PUBLISHER .p8 identity (the relay's own key), NOT the per-installation E2E key_id (Installation.KeyID / Envelope.KeyID, which is an HPKE key label the relay can't sign with). main.go pre-seeds the publisher tuple; Phase-3 send wiring must pass s.KeyID, never inst.KeyID.

func (*ClientCache) Invalidate

func (c *ClientCache) Invalidate(keyID, teamID, bundleID string, env apns.Env)

Invalidate drops the cached client for a tuple. Call after a .p8 rotation so the next send mints a new Signer under the new key rather than reusing the stale JWT from the old key.

type ClientFactory

type ClientFactory func(keyID, teamID, bundleID string, env apns.Env, key *ecdsa.PrivateKey) *apns.Client

ClientFactory builds an *apns.Client for a publisher identity. It is injected so the cache can be tested without minting real Signers/JWTs: the 429 invariant (2026-08-07) is "exactly one client — and thus one Signer + one ~50-min provider JWT — per (keyID,teamID,bundleID,env), no matter how many workers race". A counting factory in the regression test proves the cache collapses a burst to a single factory call.

type Credential

type Credential struct {
	DaemonID       string
	InstallationID string // optional: credentials may be installation-scoped
	ExpiresAt      time.Time
}

Credential is the authenticated daemon identity a request resolves to. The relay stamps cred.DaemonID as the APNs payload origin (org) so the iPhone can route a tapped notification to the originating host (1:N model). Access to a given installation is gated by Store.Authorized (the enforced authz check); a credential alone does not authorize pushing to any installation.

type Installation

type Installation struct {
	ID                string
	E2EPubKey         []byte // X25519 HPKE public key; daemons seal alerts to it
	KeyID             string
	DeviceToken       string // APNs push token for ordinary alerts
	LAToken           string // APNs push token for the Live Activity (optional)
	Env               apns.Env
	AuthorizedDaemons map[string]struct{}
	// TokenStale is set when APNs reports the device token unregistered. The
	// queue skips pushes to a stale install until the iPhone re-registers a
	// fresh token (Register clears it), so a dead token stops generating a
	// failed send per queued alert (Phase 3 stale-token removal).
	TokenStale bool
}

Installation is the relay's per-iPhone-install record. The 1:N model: one iPhone install (one E2E keypair + one device token) may be paired with N daemons (Mac, Linux, …). AuthorizedDaemons is the set of daemon_ids that may push to this install; a daemon may submit only if its credential's daemon_id is in the set. Unlinking one daemon removes only its entry, keeping the others.

This is the net-new multi-tenant registry (contract §5); it is NOT the single-tenant daemon apns.Store. It is in-memory for the skeleton — the durable store lands in Phase 3.

type PushAlertsRequest

type PushAlertsRequest struct {
	InstallationID string          `json:"installation_id"`
	KeyID          string          `json:"key_id"`
	CollapseHint   string          `json:"collapse_hint"` // relay uses verbatim as apns-collapse-id
	ExpiresAt      int64           `json:"expires_at"`
	Envelopes      []hpke.Envelope `json:"envelopes"`
}

PushAlertsRequest is POST /v1/push/alerts.

type PushResult

type PushResult struct {
	RequestID    string `json:"request_id"`
	Accepted     int    `json:"accepted"`
	Deduplicated int    `json:"deduplicated"`
	RetryAfter   int    `json:"retry_after,omitempty"` // seconds, set under admission control (Phase 3)
}

PushResult is the 202 body for both push routes.

type RegisterRequest

type RegisterRequest struct {
	InstallationID string   `json:"installation_id"`
	DaemonID       string   `json:"daemon_id"`
	Env            apns.Env `json:"env"`
	DeviceToken    string   `json:"device_token,omitempty"`
	LAToken        string   `json:"la_token,omitempty"`
	E2EPubKey      []byte   `json:"e2e_pubkey,omitempty"`
	KeyID          string   `json:"key_id,omitempty"`
	ProtoVersion   int      `json:"proto_version,omitempty"`
}

RegisterRequest is the body of POST /v1/installations/register. The JSON tags MUST mirror relayclient.registerRequest (snake_case) — without them the decoder matched on PascalCase field names and device_token/e2e_pubkey silently stayed empty, so the relay stored installs it could never actually push to.

type Server

type Server struct {
	Store         *Store
	Clients       *ClientCache
	ConfiguredEnv apns.Env
	KeyID         string
	TeamID        string
	BundleID      string

	// Resolve maps a request to its daemon credential. nil result ⇒ 401. Tests
	// inject a fixed resolver; the production default is bearerResolve.
	Resolve func(r *http.Request) (*Credential, error)
	// contains filtered or unexported fields
}

Server is the relay HTTPS endpoint. It owns the installation registry, the cached APNs client, and the publisher identity (.p8). Daemon credentials are resolved per-request via Resolve; the default impl verifies a publisher-minted Ed25519-signed bearer (relayauth.Verify), with an optional static bootstrap bearer for local dev.

func NewServer

func NewServer(store *Store, clients *ClientCache, env apns.Env, keyID, teamID, bundleID string) *Server

NewServer builds a relay server. clients may be nil (created with the default factory). Resolve defaults to bearerResolve (signed-credential verification).

func (*Server) Close

func (s *Server) Close()

Close stops the async alert lane's worker (abandoning pending items — the 2s TTL would expire them on a fresh relay anyway). Call on shutdown so the worker goroutine exits cleanly instead of leaking past the drain window.

func (*Server) FlushAlerts

func (s *Server) FlushAlerts()

FlushAlerts blocks until the alert lane has drained and no send is in flight. Tests use it for deterministic async assertions.

func (*Server) Handler

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

Handler returns the mux with all /v1 routes registered.

func (*Server) SetAlertSender

func (s *Server) SetAlertSender(fn func(ctx context.Context, payload map[string]any, deviceToken string, opts apns.SendOpts) error)

SetAlertSender overrides the alert sender (the default uses the cached client). Tests inject a recorder so the relay can accept envelopes without a real .p8.

func (*Server) SetBootstrapCredential

func (s *Server) SetBootstrapCredential(token, daemonID string)

SetBootstrapCredential arms the static pre-shared bearer (local dev). daemonID is stamped as the alert org + used for authorization; it must match the credential the daemon presents so register and submit resolve to the same identity. No-op when token is empty (production: use -verify-key).

func (*Server) SetLiveActivitySender

func (s *Server) SetLiveActivitySender(fn func(ctx context.Context, payload map[string]any, laToken string, opts apns.SendOpts) error)

SetLiveActivitySender overrides the LA sender (default = cached client). Tests inject a recorder so the relay can accept activities without a real .p8.

func (*Server) SetPublisherKey

func (s *Server) SetPublisherKey(key *ecdsa.PrivateKey)

SetPublisherKey sets the relay's APNs provider key (.p8), enabling the default alert sender (apns.Client.SendRaw via the cached client). main loads the .p8 once at startup and calls this; the cached-client invariant (one Signer per identity) lives in ClientCache.

func (*Server) SetVerifyKey

func (s *Server) SetVerifyKey(key ed25519.PublicKey)

SetVerifyKey arms the publisher's Ed25519 public key for signed-credential verification. Production relays MUST call this (or accept only the local-dev bootstrap bearer).

type Store

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

Store is the in-memory installation registry.

func NewStore

func NewStore() *Store

func (*Store) Authorized

func (s *Store) Authorized(installationID, daemonID string) bool

Authorized reports whether daemonID may push to installationID.

func (*Store) Get

func (s *Store) Get(installationID string) (Installation, bool)

Get returns a snapshot copy of the installation, if present. Callers receive a detached value so they cannot race the store by mutating the live record.

func (*Store) IsStale

func (s *Store) IsStale(installationID string) bool

IsStale reports whether an installation's device token is flagged unregistered.

func (*Store) MarkStale

func (s *Store) MarkStale(installationID string) bool

MarkStale flags an installation's device token as unregistered so the queue stops pushing until the iPhone re-registers. Idempotent. Returns false if the installation is not found.

func (*Store) Register

func (s *Store) Register(req RegisterRequest) (Installation, bool)

Register upserts an installation: it creates the install (first daemon for this iPhone) or adds daemonID to an existing install's authorized set. It does NOT replace existing device/E2E-key material — a second daemon pairing adds itself to the authorized set, it does not overwrite the iPhone's keys/tokens. Returns a snapshot copy and whether the installation was newly created.

func (*Store) UnregisterDaemon

func (s *Store) UnregisterDaemon(installationID, daemonID string) bool

UnregisterDaemon removes one daemon from an installation's authorized set. It keeps the installation for the remaining daemons; the install record is dropped only when the last daemon is removed (the iPhone re-pairs fresh). Returns false if the installation was not found.

Jump to

Keyboard shortcuts

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