notify

package
v0.2.8 Latest Latest
Warning

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

Go to latest
Published: Aug 17, 2026 License: AGPL-3.0 Imports: 16 Imported by: 0

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

View Source
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

func New(lg *log.Logger) *Client

New constructs a Client with Expo's hosted endpoint. A nil logger uses a stderr-prefixed default.

func NewWithEndpoint

func NewWithEndpoint(endpoint string, lg *log.Logger) *Client

NewWithEndpoint constructs a Client targeting an arbitrary endpoint. Used by tests (httptest.Server) and by self-hosters proxying Expo.

func (*Client) Push

func (c *Client) Push(ctx context.Context, msgs []Message) ([]Ticket, error)

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

func (c *Client) WithAccessToken(token string) *Client

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

func (c *Client) WithExperienceID(id string) *Client

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:

  1. Read Authorization: Bearer <token>.
  2. Resolve token → host → user_id.
  3. Decode the notifier.Notification body.
  4. Load the user's registered devices.
  5. Translate to Expo Messages and Push.
  6. 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.

const (
	PriorityDefault Priority = "default"
	PriorityHigh    Priority = "high"
)

type Pusher

type Pusher interface {
	Push(ctx context.Context, msgs []Message) ([]Ticket, error)
}

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

func (t Ticket) IsDeviceNotRegistered() bool

IsDeviceNotRegistered reports whether the ticket indicates a dead push token. Callers use this to purge stale devices rows.

func (Ticket) IsMismatchedExperience

func (t Ticket) IsMismatchedExperience() bool

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

func (t Ticket) IsUndeliverable() bool

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.

Jump to

Keyboard shortcuts

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