Documentation
¶
Overview ¶
Package notify implements the clankd-side push delivery for host-emitted notifier webhooks. It owns:
- the Expo Push API client (this file)
- the per-user devices registry (devices.go)
- the HTTP dispatcher that ties them together (dispatcher.go)
External services (Expo, APNs, FCM) live behind one Provider-style abstraction so self-hosters can swap in their own delivery without patching dispatcher logic.
Index ¶
- Constants
- type Client
- type Device
- type DeviceStore
- type Dispatcher
- func (d *Dispatcher) DeleteDevicesByUser(ctx context.Context, userID string) error
- func (d *Dispatcher) Handle(w http.ResponseWriter, r *http.Request)
- func (d *Dispatcher) HandleDeregister(w http.ResponseWriter, r *http.Request)
- func (d *Dispatcher) HandleRegister(w http.ResponseWriter, r *http.Request)
- type HostLookup
- type Message
- type Priority
- type Pusher
- type Ticket
Constants ¶
const ( // MismatchedExperienceID is a clankd-synthesized ticket error — not // an Expo code. It marks a token that Expo attributed to a different // experience than the one this client is pinned to (WithExperienceID): // undeliverable from this deployment, so the dispatcher purges it. MismatchedExperienceID = "MismatchedExperienceId" )
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type Client ¶
type Client struct {
// contains filtered or unexported fields
}
Client is the Expo Push API client. Construct with New.
func New ¶
New constructs a Client with Expo's hosted endpoint. A nil logger uses a stderr-prefixed default.
func NewWithEndpoint ¶
NewWithEndpoint constructs a Client targeting an arbitrary endpoint. Used by tests (httptest.Server) and by self-hosters proxying Expo.
func (*Client) Push ¶
Push delivers msgs to Expo, chunking at maxBatchSize. Returns one Ticket per input message, in the same order. A whole-batch error (HTTP failure) is returned without tickets; per-message errors surface inside their tickets so the caller can still purge dead tokens for the messages that did get processed.
func (*Client) WithAccessToken ¶
WithAccessToken sets the Expo Access Token sent as "Authorization: Bearer <token>" on every Push call. Production deployments mint one at expo.dev → access tokens and supply it via env (e.g. EXPO_ACCESS_TOKEN); without it the Push API still accepts requests but any caller who steals a push token can abuse it because the token is the only routing key. Empty disables the header — fine for dev.
Chainable so the wiring stays a one-liner:
notify.New(lg).WithAccessToken(os.Getenv("EXPO_ACCESS_TOKEN"))
func (*Client) WithExperienceID ¶
WithExperienceID pins the client to one Expo experience (project), e.g. "@supaclank/clank". When a mixed batch gets split per experience (see pushPerExperience), tokens Expo attributes to any other experience are not re-sent; they come back as MismatchedExperienceId tickets so the dispatcher purges their device rows. Empty (default) re-sends every group — right for self-hosters who don't know which app builds their users run.
Chainable, like WithAccessToken. Production supplies it via env (e.g. EXPO_EXPERIENCE_ID).
type Device ¶
type Device struct {
UserID string
PushToken string
Platform string
CreatedAt time.Time
LastSeenAt time.Time
}
Device is the dispatcher's view of a registered push token. Mirrors internal/store.Device with the same field layout — passing through without conversion at the store boundary.
type DeviceStore ¶
type DeviceStore interface {
UpsertDevice(ctx context.Context, d Device) error
ListDevicesByUser(ctx context.Context, userID string) ([]Device, error)
DeleteDevice(ctx context.Context, userID, pushToken string) error
DeleteDeviceByPushToken(ctx context.Context, pushToken string) error
}
DeviceStore is the subset of internal/store.Store's devices API that the dispatcher needs. Lets tests inject a tiny in-memory fake without importing the SQLite store.
type Dispatcher ¶
type Dispatcher struct {
// contains filtered or unexported fields
}
Dispatcher receives notifier webhooks from hosts, resolves each to its owning user, and fans the notification out to every registered device. Construct with NewDispatcher.
func NewDispatcher ¶
func NewDispatcher(hosts HostLookup, devices DeviceStore, pusher Pusher, lg *log.Logger) *Dispatcher
NewDispatcher wires up the dispatcher. A nil logger uses a stderr- prefixed default; the other dependencies are required.
func (*Dispatcher) DeleteDevicesByUser ¶
func (d *Dispatcher) DeleteDevicesByUser(ctx context.Context, userID string) error
DeleteDevicesByUser removes every push-token row registered for userID. Used by account erasure. Idempotent — a user with no devices is a no-op. Aborts on the first delete error so the caller can retry; already-deleted rows are skipped on the retry (DeleteDevice is itself idempotent).
func (*Dispatcher) Handle ¶
func (d *Dispatcher) Handle(w http.ResponseWriter, r *http.Request)
Handle is bound at POST /webhooks/notifications. Flow:
- Read Authorization: Bearer <token>.
- Resolve token → host → user_id.
- Decode the notifier.Notification body.
- Load the user's registered devices.
- Translate to Expo Messages and Push.
- Purge any device row whose ticket says the token is permanently undeliverable (Ticket.IsUndeliverable).
Status codes:
202 — accepted and dispatched (or accepted with zero devices). 400 — body decode failure. 401 — missing/unknown bearer token. 502 — Expo push failed (transport/whole-batch error).
func (*Dispatcher) HandleDeregister ¶
func (d *Dispatcher) HandleDeregister(w http.ResponseWriter, r *http.Request)
HandleDeregister is bound at DELETE /devices/{token}. Removes the (user, token) row. No-op when the row isn't there.
func (*Dispatcher) HandleRegister ¶
func (d *Dispatcher) HandleRegister(w http.ResponseWriter, r *http.Request)
HandleRegister is bound at POST /devices behind the user-bearer auth middleware. Body: {"push_token": "...", "platform": "ios"|"android"}. Idempotent — re-registering the same token refreshes last_seen_at. Registration that pushes the user past maxDevicesPerUser evicts their stalest tokens.
type HostLookup ¶
type HostLookup interface {
GetHostByNotifierToken(ctx context.Context, notifierToken string) (hoststore.Host, error)
}
HostLookup resolves a notifier bearer token to a host record. The dispatcher reads UserID off the result; HostID is exposed for logs and future per-host throttling.
hoststore.HostStore satisfies this contract via GetHostByNotifierToken, so production wiring passes the existing daemon store; tests can substitute a fake.
type Message ¶
type Message struct {
To string `json:"to"`
Title string `json:"title,omitempty"`
Body string `json:"body,omitempty"`
Data map[string]any `json:"data,omitempty"`
Priority Priority `json:"priority,omitempty"`
Sound string `json:"sound,omitempty"`
}
Message is a single push payload. Mirrors Expo's request shape but with Go-friendly types. Callers fill To, Title, Body; the dispatcher fills Data (so the mobile client can deep-link) and Priority.
type Priority ¶
type Priority string
Priority is the Expo-defined urgency tier. We only emit "high" for notifications that should bypass low-power mode (idle / permission / error) — everything else stays default.
type Pusher ¶
Pusher is the delivery contract. *Client satisfies it; tests substitute fakes that capture sent messages without HTTP.
type Ticket ¶
type Ticket struct {
Status string `json:"status"`
ID string `json:"id,omitempty"`
Message string `json:"message,omitempty"`
Details struct {
Error string `json:"error,omitempty"`
} `json:"details,omitempty"`
}
Ticket is Expo's per-message acknowledgement. Status is "ok" or "error"; on error, Details.Error categorizes (the canonical value we act on is "DeviceNotRegistered" — the token is dead and should be purged).
func (Ticket) IsDeviceNotRegistered ¶
IsDeviceNotRegistered reports whether the ticket indicates a dead push token. Callers use this to purge stale devices rows.
func (Ticket) IsMismatchedExperience ¶
IsMismatchedExperience reports whether the ticket marks a token that belongs to a different Expo experience than this client sends for (see MismatchedExperienceID). Callers purge these like dead tokens.
func (Ticket) IsUndeliverable ¶
IsUndeliverable reports whether the token can never receive a push from this deployment, so its device row should be purged rather than retried. This is the provider-agnostic predicate the dispatcher acts on; which error codes qualify (a dead token, a token pinned out by WithExperienceID) is Expo-specific detail that stays here. A future Pusher impl decides its own permanent-failure codes behind the same method.