// Package slack: tenant Slack-webhook subscriptions and guarded delivery. // // A Slack Endpoint is the Slack analog of a webhooks.Endpoint: a tenant // subscription that turns domain events into messages posted to a Slack // incoming-webhook URL. It differs from a generic webhook in two ways that // shape this package: // // - The delivery payload is a Slack Message (text and/or Block Kit), not the // raw event JSON, so each event carries a rendering the caller authors (the // per-event template lives at the emit call site; Fanout only marshals it). // - Slack incoming webhooks are not signed. The webhook URL itself is the // secret — anyone holding it can post — so the URL is sealed at rest under // the tenant DEK (like a webhooks signing secret) and never returned to a // client, and there is no HMAC signature on delivery. package slack import ( "context" "encoding/json" "atlas9.dev/c/core" "atlas9.dev/c/core/iam" ) // Message is a Slack message payload. Text is the simple/fallback message; // Blocks carries Block Kit for stylised messages (sections, fields, buttons, // etc.) and is left opaque so a caller can build any Slack layout without this // package modeling the whole Block Kit schema. A message with only Text is the // common case; a rich caller sets Blocks (and usually a Text fallback, which // Slack shows in notifications and clients that don't render blocks). // // MarshalJSON emits Slack's incoming-webhook contract (lowercase "text" / // "blocks"), so a feature's template can stay in PascalCase Go and still produce // a valid Slack body. type Message struct { Text string Blocks []any } func (m Message) MarshalJSON() ([]byte, error) { out := map[string]any{} if m.Text != "" { out["text"] = m.Text } if len(m.Blocks) > 0 { out["blocks"] = m.Blocks } return json.Marshal(out) } // Endpoint is a tenant's registered Slack subscription: an incoming-webhook URL // that receives a message for each subscribed event type. The URL is a secret // (it grants posting to the channel), so it is held sealed (DekID + URLEnc) at // rest, mirroring a webhooks signing secret; the store carries it opaquely and // never decrypts. The plaintext URL is supplied on create/update and never read // back. type Endpoint struct { ID core.ID Tenant core.ID Name string EventTypes []string Active bool DekID core.ID URLEnc []byte } var ( Cap_Slack_CreateEndpoint = iam.NewCap("Slack_CreateEndpoint") Cap_Slack_UpdateEndpoint = iam.NewCap("Slack_UpdateEndpoint") Cap_Slack_ReadEndpoint = iam.NewCap("Slack_ReadEndpoint") Cap_Slack_DeleteEndpoint = iam.NewCap("Slack_DeleteEndpoint") // Cap_Slack_Send is the app's egress boundary for the Slack channel, the // analog of webhooks.Cap_Webhooks_Send. Deliveries are made system-wide by // the worker, so it is checked system-wide. Cap_Slack_Send = iam.NewCap("Slack_Send") ) // Store persists Slack endpoints. Endpoint methods are tenant-scoped and // capability-checked like any other resource store. type Store interface { CreateEndpoint(ctx context.Context, e *Endpoint) error UpdateEndpoint(ctx context.Context, e *Endpoint) error GetEndpoint(ctx context.Context, tenant core.ID, id core.ID, out *Endpoint) error ListEndpoints(ctx context.Context, tenant core.ID, page core.PageReq, out *core.Page[Endpoint]) error DeleteEndpoint(ctx context.Context, tenant core.ID, id core.ID) error // ListActiveForEvent returns the tenant's active endpoints subscribed to // eventType, for fanout. Guarded by Cap_Slack_ReadEndpoint like the other // reads; Fanout.Emit grants it scoped to the tenant, since the emitting // caller is mid-mutation and needn't hold Slack caps. ListActiveForEvent(ctx context.Context, tenant core.ID, eventType string) ([]Endpoint, error) }