Documentation
¶
Index ¶
- func DefaultClientFactory(keyID, teamID, bundleID string, env apns.Env, key *ecdsa.PrivateKey) *apns.Client
- type ClientCache
- type ClientFactory
- type Credential
- type Installation
- type PushAlertsRequest
- type PushResult
- type RegisterRequest
- type Server
- func (s *Server) Close()
- func (s *Server) FlushAlerts()
- func (s *Server) Handler() http.Handler
- func (s *Server) SetAlertSender(...)
- func (s *Server) SetBootstrapCredential(token, daemonID string)
- func (s *Server) SetLiveActivitySender(...)
- func (s *Server) SetPublisherKey(key *ecdsa.PrivateKey)
- func (s *Server) SetVerifyKey(key ed25519.PublicKey)
- type Store
- func (s *Store) Authorized(installationID, daemonID string) bool
- func (s *Store) Get(installationID string) (Installation, bool)
- func (s *Store) IsStale(installationID string) bool
- func (s *Store) MarkStale(installationID string) bool
- func (s *Store) Register(req RegisterRequest) (Installation, bool)
- func (s *Store) UnregisterDaemon(installationID, daemonID string) bool
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) 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 ¶
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 ¶
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 (*Store) Authorized ¶
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 ¶
IsStale reports whether an installation's device token is flagged unregistered.
func (*Store) MarkStale ¶
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 ¶
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.