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

subscription

package
v0.16.0 Latest Latest
Warning

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

Go to latest
Published: Oct 26, 2026 License: AGPL-3.0 Imports: 41 Imported by: 0

Documentation

Overview

Package subscription delivers Pando's events to the people who asked for them: webhooks and notification channels, retried until they arrive or plainly fail (issue #50, R-366 – R-374, design 11).

The service is what the API calls, and through it the CLI, MCP and console (R-261). The dispatcher is the loop that routes the outbox and sends. The router sends Pando's own notifications to the people they are for, as each person's preferences allow.

Index

Constants

View Source
const (
	DisableAfter       = 24 * time.Hour
	DisableMinFailures = 5
)

Disabling thresholds [P]: an endpoint that has failed every attempt for a day, and at least five times, is turned off (R-370). Both, so neither one slow failure in a quiet install nor a burst of them in a busy one does it.

View Source
const (
	HeaderSignature = "Pando-Signature"
	HeaderTimestamp = "Pando-Timestamp"
	HeaderEventID   = "Pando-Event-Id"
	HeaderEvent     = "Pando-Event"
	HeaderDelivery  = "Pando-Delivery-Id"
)

Headers on every webhook delivery (R-369).

View Source
const Retention = 30 * 24 * time.Hour

Retention is how long the outbox keeps an event once it has nothing left to deliver [P]. The audit log is the history; this is a queue (R-366).

View Source
const WatchedSubscriptionCache = 5 * time.Minute

WatchedSubscriptionCache is how long the enabled subscriptions are reused while this replica is listening for changes to them [P]: every change, made on any replica, drops the list at once, so this only bounds how long a list lives if a notification is somehow lost.

Variables

View Source
var Kinds = []Kind{
	{api.NotifyAppFailed, "An app failed", "An app you own failed and Pando stopped restarting it.", true},
	{api.NotifyDeployFailed, "A deploy failed", "A deploy you started, or of an app you own, failed.", true},
	{api.NotifyDeployApproval, "Deploy approvals", "A deploy is waiting for your approval, or one you asked for was answered.", true},
	{api.NotifyPolicyViolation, "Below the security minimum", "An app you own is below the installation's minimum security score.", true},
	{api.NotifyBackupFailed, "A backup failed", "A backup of an app you own was not taken.", true},
	{api.NotifyAppDisk, "An app near its disk limit", "An app you own is near or over its disk limit, or Pando stopped it for staying over.", true},
	{api.NotifyAppIdle, "An app nobody uses", "An app you own is about to be stopped or deleted because nobody has used it, or was.", true},
	{api.NotifyAppOrphaned, "An app with no owner", "None of an app's owners has an active account. Sent to administrators.", true},
	{api.NotifyUpdateAvailable, "Pando updates", "A newer Pando is released. Sent to people who may upgrade it.", true},
	{api.NotifyUpgradeFailed, "Upgrade failed", "An in-place upgrade of Pando did not finish.", true},
	{api.NotifySubscriptionDisabled, "A subscription was turned off", "One of your event subscriptions kept failing and Pando turned it off.", true},
	{api.NotifyAuditSinkDisabled, "An audit destination was turned off", "A destination the audit log is sent to kept failing and Pando turned it off. Sent to people who may send the audit log off the installation.", true},

	{api.NotifyAppShared, "An app was shared with you", "Somebody gave you use of an app. It is in your launcher either way.", false},
}

Kinds is every notification Pando sends on its own, in the order the preferences screen lists them.

View Source
var RetrySchedule = []time.Duration{
	time.Minute, 5 * time.Minute, 30 * time.Minute, 2 * time.Hour, 6 * time.Hour, 12 * time.Hour,
}

RetrySchedule is how long to wait after each failed attempt before the next [P]. The first attempt is immediate; after the last of these fails the delivery is marked failed and can be sent again by hand. About a day in all, which is also how long an endpoint may fail everything before it is turned off — so a delivery outlives a short outage and does not outlive a dead one.

Functions

func CheckURL

func CheckURL(ctx context.Context, raw string, allowPrivate bool, resolver Resolver) (string, error)

CheckURL refuses a webhook address before it is saved, so a person finds out now rather than from a delivery log (R-372). The dialer checks again at every send; this is the readable half, not the enforcing one.

func Describe

func Describe(e state.Event, app *EnvelopeApp) api.Notification

Describe is an event as a message a person reads: a subject naming what happened and to what, and the event's fields under it. Notify adapters lay these out for their platform; none of them has to know the catalog.

func IsKind

func IsKind(kind string) bool

IsKind reports whether kind is one Pando sends on its own.

func LinkFor

func LinkFor(externalURL, appID string) string

LinkFor is where in the console an event is seen: the app's page for an app event, the Events screen otherwise. Empty when Pando does not know its own address (external_url).

func Matching

func Matching(subs []state.Subscription, e state.Event) []string

Matching is which subscriptions an event goes to: those whose filter names it, on its app or install-wide, made before the event happened. An event with no app reaches install-wide subscriptions only. A test event is never routed; it was made for one subscription.

func NewKey

func NewKey() (secret.Value, error)

NewKey returns a fresh signing key: 32 random bytes.

func Sign

func Sign(key secret.Value, timestamp int64, body []byte) string

Sign returns the signature header for a body sent at timestamp: the HMAC-SHA256, keyed by the whole signing key as given, of the timestamp in Unix seconds, a full stop, and the body exactly as sent.

The timestamp is inside what is signed so a receiver that refuses old timestamps refuses a replayed delivery, and the event ID is in a header so a receiver can drop a delivery it already has (R-369).

func Verify

func Verify(key secret.Value, header string, timestamp int64, body []byte, now time.Time, tolerance time.Duration) bool

Verify is Sign's other half: what a receiver does, here so the documentation's example and the tests check the same thing Pando sends.

Types

type Actor

type Actor struct {
	Kind       string `json:"kind"`
	ID         string `json:"id,omitempty"`
	OnBehalfOf string `json:"on_behalf_of,omitempty"`
}

Actor is who caused an event: a person, a token, or Pando itself.

type ActorNames added in v0.15.0

type ActorNames interface {
	ByID(ctx context.Context, userID string) (state.User, bool, error)
}

ActorNames looks a person up by ID.

type Apps

type Apps interface {
	ByID(ctx context.Context, appID string) (state.App, bool, error)
}

Apps looks an app up.

type Authorizer

type Authorizer interface {
	CheckControl(ctx context.Context, p authz.Principal, appID string, verb authz.Verb) error
	CheckInstall(ctx context.Context, p authz.Principal, verb authz.Verb) error
	Allows(ctx context.Context, p authz.Principal, appID string, verb authz.Verb) (bool, error)
	AllowsInstall(ctx context.Context, p authz.Principal, verb authz.Verb) (bool, error)
}

Authorizer is the part of authz.Authorizer the service uses.

type Channel

type Channel struct {
	ID   string `json:"id"`
	Kind string `json:"kind"`
}

Channel is a notification adapter that reaches people, as a preferences screen offers it.

type CreateRequest

type CreateRequest struct {
	// AppID is the app it is about; empty subscribes install-wide.
	AppID       string   `json:"app_id,omitempty"`
	Events      []string `json:"events"`
	Destination string   `json:"destination"`
	URL         string   `json:"url,omitempty"`
	AdapterID   string   `json:"adapter_id,omitempty"`
	Description string   `json:"description,omitempty"`

	// Channel is the channel an adapter whose capabilities say Channels
	// posts to, from GET /notify/{id}/channels; refused for every other.
	Channel string `json:"channel,omitempty"`

	// How a webhook is sent (R-375). Header values are secret.Value: they
	// are read from the request, sealed, and never written back out.
	Method          string                  `json:"method,omitempty"`
	ContentType     string                  `json:"content_type,omitempty"`
	Headers         map[string]secret.Value `json:"headers,omitempty"`
	PayloadTemplate string                  `json:"payload_template,omitempty"`
}

CreateRequest is a new subscription.

type Created

type Created struct {
	state.Subscription
	SigningKey *secretOnce `json:"signing_key,omitempty"`
}

Created is a subscription as it was made, with its signing key — shown this once and never again (R-371).

type DeliveryDetail

type DeliveryDetail struct {
	state.Delivery
	// Payload is the body as it is sent: JSON when it is JSON, otherwise
	// the text of a templated body.
	Payload any `json:"payload"`
}

DeliveryDetail is one delivery, every attempt at it, and what was sent.

type Deployments

type Deployments interface {
	ByID(ctx context.Context, deploymentID string) (state.Deployment, bool, error)
}

Deployments reads who started a deploy.

type Destination

type Destination struct {
	ID       string             `json:"id"`
	Name     string             `json:"name"`
	Kind     string             `json:"kind"`
	Audience api.NotifyAudience `json:"audience"`

	// Channels says a subscription to it names a channel, from
	// GET /notify/{id}/channels.
	Channels bool `json:"channels,omitempty"`
}

Destination is a notification adapter a subscription may send through.

type Dispatcher

type Dispatcher struct {
	Events        *state.Events
	Subscriptions *state.Subscriptions
	Deliveries    *state.Deliveries
	Keys          *state.SubscriptionSecrets

	// Tokens resolves an account token that owns a subscription (R-060).
	Tokens interface {
		Active(ctx context.Context, tokenID string) (state.Token, bool, error)
	}

	// ExternalURL is how a browser reaches Pando, for the link in each
	// delivery. Empty sends none.
	ExternalURL string

	// Who Pando's own failure notifications go to (announce.go). Each is
	// optional; without one, those people are not told.
	Deployments Deployments
	Holders     VerbHolders
	TokenOwners TokenOwners

	Authz    Authorizer
	Policy   PolicyLoader
	Apps     Apps
	Users    Users
	Groups   Groups
	Registry *api.Registry

	// Notifiers are the notification adapters a subscription sends through.
	// Nil reads them from Registry.
	Notifiers Notifiers

	// Notifier tells a subscription's owner it was turned off. Optional.
	Notifier interface {
		Notify(ctx context.Context, n api.Notification) error
	}

	Audit  func(ctx context.Context, e audit.Event)
	Clock  clock.Clock
	Logger *zap.Logger

	// Client sends webhooks. Nil builds the guarded client (R-372); tests
	// supply their own only to reach a local server, with policy allowing it.
	Client *http.Client

	// Interval is how often the outbox is read when it was empty last time.
	Interval time.Duration

	// SubscriptionCache is how long the list of enabled subscriptions is
	// reused for routing [P]: when zero, WatchedSubscriptionCache while Run is
	// listening for changes (migration 61) and three seconds while it is not;
	// never when negative. Routing used to read it — three joins — on every
	// pass of every replica (issue #72). It decides only which deliveries are queued;
	// whether one is sent is decided at send time, as its owner, now (R-368),
	// and a subscription turned off is not sent to however it was routed.
	SubscriptionCache time.Duration

	// Senders is how many deliveries are sent at once. Eight when zero.
	Senders int
	// contains filtered or unexported fields
}

Dispatcher routes the outbox and sends what it routes.

func (*Dispatcher) AdapterHealth

func (d *Dispatcher) AdapterHealth(maxAge time.Duration) []telemetry.AdapterHealth

AdapterHealth is the last round's health checks, or nothing when there has been none within maxAge. Only the leader checks (WatchAdapters is a leader job), so a replica that stopped leading stops reporting rather than repeat what it last saw.

func (*Dispatcher) CheckAdapters

func (d *Dispatcher) CheckAdapters(ctx context.Context)

CheckAdapters is one round of WatchAdapters.

func (*Dispatcher) Pass

func (d *Dispatcher) Pass(ctx context.Context) bool

Pass routes what is waiting and sends what is due, once, and reports whether there was anything to do. Run does the same continuously; Pass is the one-shot form, for a test or a caller that wants it done now.

func (*Dispatcher) Run

func (d *Dispatcher) Run(ctx context.Context)

Run routes and delivers until ctx ends.

Routing and sending are separate loops (issue #72). Sending pulls deliveries as senders come free, Senders at a time, rather than claiming a batch and waiting for the slowest webhook in it before claiming the next; routing tells it when it has queued something. Pruning the outbox is the retention job's, on the leader: it used to run here, on every replica.

func (*Dispatcher) WatchAdapters

func (d *Dispatcher) WatchAdapters(ctx context.Context, interval time.Duration)

WatchAdapters checks every adapter's health every interval and records a change as an event: adapter.unhealthy when a check starts failing, adapter.recovered when it passes again. An adapter healthy since start says nothing.

type Envelope

type Envelope struct {
	ID         string         `json:"id"`
	Type       string         `json:"type"`
	OccurredAt time.Time      `json:"occurred_at"`
	App        *EnvelopeApp   `json:"app,omitempty"`
	Actor      Actor          `json:"actor"`
	Data       map[string]any `json:"data"`

	// Link is where in the console to look, when Pando's external_url is
	// set (R-369).
	Link string `json:"link,omitempty"`
}

Envelope is the body of a webhook delivery. Its shape is the contract with whoever receives it, documented in docs/events.md; data is the catalogued fields of the event and nothing else.

type EnvelopeApp

type EnvelopeApp struct {
	ID   string `json:"id"`
	Name string `json:"name,omitempty"`
	Slug string `json:"slug,omitempty"`
}

EnvelopeApp names the app an event is about.

type FeedItem

type FeedItem struct {
	Envelope
	// ActorName is the person who acted, or the person a token acted for, as
	// they are named now. Only the feed carries it: a webhook receiver gets
	// IDs, and names somebody by its own records.
	ActorName string                  `json:"actor_name,omitempty"`
	Subject   string                  `json:"subject"`
	Body      string                  `json:"body,omitempty"`
	Fields    []api.NotificationField `json:"fields,omitempty"`
}

FeedItem is one event in an app's feed: what the envelope carries, and the same event described for a person.

type Groups

type Groups interface {
	GroupsForUser(ctx context.Context, userID string) ([]string, error)
}

Groups resolves an account's groups, live (R-079).

type Inbox

type Inbox struct {
	Store *state.Notifications
}

Inbox is the console's notifications, read by the person they are for (R-377): what the console notify adapter records, which until issue #50 nothing displayed.

func (*Inbox) List

func (b *Inbox) List(ctx context.Context, p authz.Principal, unreadOnly bool, before string, limit int) (InboxPage, error)

List returns the caller's notifications, newest first.

func (*Inbox) MarkAllRead

func (b *Inbox) MarkAllRead(ctx context.Context, p authz.Principal) error

MarkAllRead marks every one of the caller's notifications read.

func (*Inbox) MarkRead

func (b *Inbox) MarkRead(ctx context.Context, p authz.Principal, notificationID string) error

MarkRead marks one of the caller's notifications read. Another person's is left alone, without saying it exists.

func (*Inbox) UnreadCount

func (b *Inbox) UnreadCount(ctx context.Context, p authz.Principal) (int, error)

UnreadCount is how many of the caller's notifications are unread: the bell's badge, which every open console asks about on every screen, so it reads the count alone rather than a page of the inbox with it (issue #72).

type InboxPage

type InboxPage struct {
	Notifications []state.Notification `json:"notifications"`
	Unread        int                  `json:"unread"`
	NextBefore    string               `json:"next_before"`
}

InboxPage is one page of a person's inbox, and how many are unread in all.

type Kind

type Kind struct {
	Kind        api.NotificationKind `json:"kind"`
	Label       string               `json:"label"`
	Description string               `json:"description"`
	DefaultOn   bool                 `json:"default_on"`
}

Kind is one of Pando's own notifications — the ones it sends to particular people without anybody subscribing — and whether it reaches them unless they say otherwise (R-373).

type ListRequest

type ListRequest struct {
	AppID string
	// Everyone lists every person's subscriptions, which needs
	// install.events.manage. Otherwise the list is the caller's own.
	Everyone bool
	// Page is which page, newest first (issue #72).
	Page state.Page
}

ListRequest narrows a list.

type Notifiers added in v0.11.0

type Notifiers interface {
	Notify(ctx context.Context, id string) (api.NotifyAdapter, bool)
	Usable(ctx context.Context) ([]notifyconn.Adapter, error)
}

Notifiers is the install's notification adapters (notifyconn.Service): built from their rows on each use, so one added or connected since startup is sent through at once.

type PolicyLoader

type PolicyLoader interface {
	Load(ctx context.Context) (policy.Document, error)
}

PolicyLoader reads host policy.

type Preferences

type Preferences interface {
	List(ctx context.Context, userID string) ([]state.NotificationPreference, error)
}

Preferences reads a person's notification choices.

type PreferencesView

type PreferencesView struct {
	Kinds    []Kind                         `json:"kinds"`
	Channels []Channel                      `json:"channels"`
	Choices  []state.NotificationPreference `json:"choices"`
}

PreferencesView is everything a person may choose and what they have chosen: every kind on every channel, defaults filled in, so a client shows the answer rather than working it out (R-261).

type Resolver

type Resolver interface {
	LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error)
}

Resolver looks a host name up. net.DefaultResolver in production.

type Result

type Result struct {
	StatusCode int
	Err        error
	Duration   time.Duration
}

Result is how one attempt went.

func (Result) Message

func (r Result) Message() string

Message is what went wrong, in a line, for the delivery log.

func (Result) OK

func (r Result) OK() bool

OK reports a 2xx answer.

type Router

type Router struct {
	Registry *api.Registry

	// Notifiers are the notification adapters. Nil reads them from Registry.
	Notifiers   Notifiers
	Preferences Preferences
	Users       Users
	Logger      *zap.Logger
}

Router sends Pando's own notifications — a deploy waiting for approval, an app that failed — to the people they name, on every channel that reaches people, as each person's preferences allow (R-373).

A channel adapter (Slack, Teams, Discord, ntfy) is skipped: a message meant for one person must not land in a room. Channels hear from subscriptions.

Best effort, like what it replaced: an adapter that fails is logged and the rest still send, and telling people never blocks what it is about (R-159).

func (Router) Notify

func (r Router) Notify(ctx context.Context, msg api.Notification) error

Notify sends msg.

type Service

type Service struct {
	Subscriptions *state.Subscriptions
	Deliveries    *state.Deliveries
	Events        *state.Events
	Keys          *state.SubscriptionSecrets

	// ExternalURL is how a browser reaches Pando, for links. Empty sends none.
	ExternalURL string
	Prefs       *state.NotificationPreferences

	Authz    Authorizer
	Policy   PolicyLoader
	Apps     Apps
	Registry *api.Registry

	// Notifiers are the notification adapters a subscription may send
	// through. Nil reads them from Registry.
	Notifiers Notifiers

	// Resolver looks webhook hosts up when one is saved. Nil uses the
	// system resolver.
	Resolver Resolver

	// Names says who a person is, so an app's feed names who acted rather
	// than showing an ID, as the audit log does (R-379). Nil leaves names out.
	Names ActorNames

	Audit  func(ctx context.Context, e audit.Event)
	Clock  clock.Clock
	Logger *zap.Logger
}

Service manages subscriptions.

func (*Service) Create

func (s *Service) Create(ctx context.Context, p authz.Principal, req CreateRequest) (Created, error)

Create makes a subscription. A webhook gets a signing key, returned here once.

func (*Service) Delete

func (s *Service) Delete(ctx context.Context, p authz.Principal, subscriptionID string) error

Delete removes a subscription, its key and its delivery log.

func (*Service) Delivery

func (s *Service) Delivery(ctx context.Context, p authz.Principal, subscriptionID, deliveryID string) (DeliveryDetail, error)

Delivery returns one delivery in full.

func (*Service) Destinations

func (s *Service) Destinations(ctx context.Context) []Destination

Destinations lists every notification adapter a subscription may send through, for a form that offers them to somebody who may not read the adapters themselves. IDs and kinds only: an adapter's configuration is the adapters screen's.

func (*Service) Feed

func (s *Service) Feed(ctx context.Context, appID, before string, limit int) ([]FeedItem, string, error)

Feed is an app's recent events, for anyone who can see the app (R-378). The handler has already checked app.view.

func (*Service) Get

func (s *Service) Get(ctx context.Context, p authz.Principal, subscriptionID string) (state.Subscription, error)

Get returns one subscription.

func (*Service) List

List returns one page of the caller's subscriptions, or everybody's, and the cursor for the next page (empty after the last).

func (*Service) ListDeliveries

func (s *Service) ListDeliveries(ctx context.Context, p authz.Principal, subscriptionID, before string, limit int) ([]state.Delivery, error)

ListDeliveries lists a subscription's recent deliveries.

func (*Service) Preferences

func (s *Service) Preferences(ctx context.Context, p authz.Principal) (PreferencesView, error)

Preferences returns the caller's notification preferences (R-373).

func (*Service) Redeliver

func (s *Service) Redeliver(ctx context.Context, p authz.Principal, subscriptionID, deliveryID string) (state.Delivery, error)

Redeliver sends a delivery again, with the whole retry schedule ahead of it.

func (*Service) RotateKey

func (s *Service) RotateKey(ctx context.Context, p authz.Principal, subscriptionID string) (Created, error)

RotateKey replaces a webhook's signing key and returns the new one, once. Deliveries signed with the old key and not yet sent are signed with the new one when they go, because a delivery is signed as it is sent.

func (*Service) SetPreferences

func (s *Service) SetPreferences(ctx context.Context, p authz.Principal, choices []state.NotificationPreference) (PreferencesView, error)

SetPreferences records the caller's choices. Each names a kind Pando sends and a channel that reaches people; anything else is refused rather than stored, so a typo does not look like a preference that took.

func (*Service) Test

func (s *Service) Test(ctx context.Context, p authz.Principal, subscriptionID string) (state.Delivery, error)

Test sends a subscription.test event to one subscription, whatever its filter says, so a person can check an endpoint before relying on it.

func (*Service) Update

func (s *Service) Update(ctx context.Context, p authz.Principal, subscriptionID string, req UpdateRequest) (state.Subscription, error)

Update changes a subscription.

type TemplateData

type TemplateData struct {
	ID         string
	Type       string
	OccurredAt time.Time
	App        EnvelopeApp
	Actor      Actor
	Data       map[string]any

	// Subject, Body and Fields are the event as a notification describes it.
	Subject string
	Body    string
	Fields  []api.NotificationField
	Link    string

	// Envelope is Pando's own body, for a template that wraps it.
	Envelope Envelope
}

TemplateData is what a body template is given: the event as the envelope carries it, and the same event described for a person.

{"text": {{json .Subject}}, "url": {{json .Link}}}
{{.Type}} on {{.App.Name}}: {{index .Data "error_code"}}

type TokenOwners

type TokenOwners interface {
	Owner(ctx context.Context, tokenID string) (string, bool, error)
}

TokenOwners resolves a delegated token to the person it acts for.

type UpdateRequest

type UpdateRequest struct {
	Events          *[]string                `json:"events,omitempty"`
	URL             *string                  `json:"url,omitempty"`
	AdapterID       *string                  `json:"adapter_id,omitempty"`
	Channel         *string                  `json:"channel,omitempty"`
	Description     *string                  `json:"description,omitempty"`
	Enabled         *bool                    `json:"enabled,omitempty"`
	Method          *string                  `json:"method,omitempty"`
	ContentType     *string                  `json:"content_type,omitempty"`
	Headers         *map[string]secret.Value `json:"headers,omitempty"`
	PayloadTemplate *string                  `json:"payload_template,omitempty"`
}

UpdateRequest changes a subscription. Nil fields are left alone. The destination's kind and the app are fixed: a different one is a different subscription. Headers, when given, replace every header the webhook had.

type Users

type Users interface {
	ByID(ctx context.Context, userID string) (state.User, bool, error)
}

Users looks an account up.

type VerbHolders

type VerbHolders interface {
	InstallVerbHolders(ctx context.Context, verb authz.Verb) ([]string, error)
}

VerbHolders lists the people holding an install verb.

Jump to

Keyboard shortcuts

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