Files
pad/internal/notify/gateway.go
T
xarmian dbbbefda75 feat(push): the push dispatcher core and its privacy gates, not yet wired (TASK-3939, part B1) (#2280)
* feat(store): record a delivery by id, unclaim without an attempt, re-check holds (TASK-3939 B1)

RecordNotificationsSent is token-free for pending|expired rows (r4-L4),
UnclaimNotifications hands never-tried rows back without an attempt,
HeldNotifications is the pre-send re-check. The claim error drops the row
id (Q8), notify.SendTimeout is exported (Q4), and the store doc says SENT
is the one deliberate exception to settle-under-the-claim (Q5).

Claude-Session: https://claude.ai/code/session_018mVC2RwgKtXviHMayjw2pg

* feat(server): the push dispatcher core and its privacy gates (TASK-3939 B1)

Claims settled rows, runs membership, liveness, visibility, assignment,
comment, settings, agent-activity and device gates on each (a skip carries a
recorded reason and never reaches the gateway), groups survivors per
(user, item), sends one content-free push per live device and settles per
chunk. Lease >= sendTimeout + margin is enforced by the setter, the send
deadline and a per-chunk time check. Not wired into cmd/pad.

Claude-Session: https://claude.ai/code/session_018mVC2RwgKtXviHMayjw2pg

* test(server): dispatcher gates, lease invariant and late-send recording (TASK-3939 B1)

Claude-Session: https://claude.ai/code/session_018mVC2RwgKtXviHMayjw2pg

* test(server): an unknown notification kind is skipped (TASK-3939 B1)

Claude-Session: https://claude.ai/code/session_018mVC2RwgKtXviHMayjw2pg

* fix(server): the dispatcher logs no ids, settles zero-message units, checks the comment's item and has a clock seam (TASK-3939 B1)

C1 reword the store header (the dispatcher is wired in part B2). H1 split
computeWatchAccessVisibility into a log-free core the dispatcher calls; the
handler's wrapper logs exactly as before. H2 settle a unit with no message as
a no_device skip. H4 a mention/comment/reply row's comment must be on the
row's item and workspace. Add the unexported now seam. T3 comment on the nil
user branch.

Claude-Session: https://claude.ai/code/session_01SSofJ73Kn2s3W4TTnjoAfU

* test(store): settle primitives assert attempts, sent_at and span more than one id chunk (TASK-3939 B1)

Claude-Session: https://claude.ai/code/session_01SSofJ73Kn2s3W4TTnjoAfU

* test(server): dispatcher fix round, clock-seam lease tests, every gate error, gate order, log leak, zero-message unit (TASK-3939 B1)

Claude-Session: https://claude.ai/code/session_01SSofJ73Kn2s3W4TTnjoAfU
2026-10-22 00:38:26 -04:00

184 lines
5.6 KiB
Go

// Package notify is the client for the push gateway (pad-cloud's
// POST /internal/push/send, contract v1 on TASK-3939). It knows nothing about
// the store: the dispatcher hands it fully built messages.
package notify
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"time"
)
// MaxBatch is the gateway's per-request message limit.
const MaxBatch = 100
// maxResponseBytes caps what is read from a gateway response. A 100-message
// result is a few KB; anything near this is not the gateway.
const maxResponseBytes = 1 << 20
// Per-message statuses.
const (
StatusSent = "sent"
StatusInvalidToken = "invalid_token"
StatusRetry = "retry"
StatusRejected = "rejected"
)
var (
// ErrBadRequest means the gateway refused the request itself (400), or
// this client refused to build an invalid one. A pad-side bug: the batch
// is skipped, not retried.
ErrBadRequest = errors.New("push gateway: bad request")
// ErrUnavailable means no usable answer: a transport error or any non-200
// other than 400 (403 wrong secret, 503 no FCM credentials, a redirect).
// Every message in the batch is a retry.
ErrUnavailable = errors.New("push gateway: unavailable")
)
// Message is one push to one device.
type Message struct {
ID string `json:"id"`
Platform string `json:"platform"`
Token string `json:"token"`
Title string `json:"title"`
Body string `json:"body"`
Data Data `json:"data"`
}
// Result is the gateway's verdict on one message.
type Result struct {
ID string `json:"id"`
Status string `json:"status"`
}
// Gateway sends batches to the push gateway.
type Gateway struct {
URL string
Secret string
HTTP *http.Client
}
// SendTimeout is the HTTP client's whole-request timeout, above the gateway's
// own 20s deadline. The dispatcher's claim lease must exceed it by a margin
// (internal/server/push_dispatch.go).
const SendTimeout = 25 * time.Second
// NewGateway returns a Gateway with a client that does not follow redirects:
// the secret rides in a header, and a redirect would carry it to wherever the
// answer points.
func NewGateway(url, secret string) *Gateway {
return &Gateway{
URL: url, Secret: secret,
HTTP: &http.Client{
Timeout: SendTimeout,
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
},
},
}
}
// Send posts msgs (1 to MaxBatch, unique ids) and returns one Result per
// message, in order. On a 200 whose results do not line up with the request
// (wrong length, a wrong or reordered id, unreadable JSON) every message
// comes back retry, since nothing in the answer can be trusted to belong to a
// device; an unknown status is a retry for that message. Otherwise the error
// is ErrBadRequest or ErrUnavailable. Neither the secret nor a token ever
// appears in an error: transport errors carry the URL, so they are replaced
// by the sentinel.
func (g *Gateway) Send(ctx context.Context, msgs []Message) ([]Result, error) {
if len(msgs) == 0 || len(msgs) > MaxBatch {
return nil, fmt.Errorf("%w: %d messages", ErrBadRequest, len(msgs))
}
seen := make(map[string]struct{}, len(msgs))
for _, m := range msgs {
if _, dup := seen[m.ID]; dup {
return nil, fmt.Errorf("%w: duplicate message id", ErrBadRequest)
}
seen[m.ID] = struct{}{}
}
payload, err := json.Marshal(struct {
Messages []Message `json:"messages"`
}{msgs})
if err != nil {
return nil, fmt.Errorf("%w: encode", ErrBadRequest)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, g.URL, bytes.NewReader(payload))
if err != nil {
return nil, fmt.Errorf("%w: build request", ErrUnavailable)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Cloud-Secret", g.Secret)
client := g.HTTP
if client == nil {
client = NewGateway(g.URL, g.Secret).HTTP
}
resp, err := client.Do(req)
if err != nil {
if ctx.Err() != nil {
return nil, fmt.Errorf("%w: %v", ErrUnavailable, ctx.Err())
}
return nil, fmt.Errorf("%w: transport error", ErrUnavailable)
}
// Drain what is left (bounded) so the connection can be reused (TASK-3939 r1-H3).
defer func() {
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, maxResponseBytes))
resp.Body.Close()
}()
switch {
case resp.StatusCode == http.StatusBadRequest:
return nil, ErrBadRequest
case resp.StatusCode != http.StatusOK:
return nil, fmt.Errorf("%w: status %d", ErrUnavailable, resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1))
if err != nil {
return nil, fmt.Errorf("%w: read response", ErrUnavailable)
}
if len(body) > maxResponseBytes {
return nil, fmt.Errorf("%w: response too large", ErrUnavailable)
}
var out struct {
Results []Result `json:"results"`
}
if json.Unmarshal(body, &out) != nil {
return allRetry(msgs), nil
}
return validateResults(msgs, out.Results), nil
}
// validateResults pins the answer to the request: the same length and the
// same ids in the same order, or the whole batch is retry. A status outside
// the four known ones is a retry for its message.
func validateResults(msgs []Message, got []Result) []Result {
if len(got) != len(msgs) {
return allRetry(msgs)
}
out := make([]Result, len(got))
for i, r := range got {
if r.ID != msgs[i].ID {
return allRetry(msgs)
}
switch r.Status {
case StatusSent, StatusInvalidToken, StatusRetry, StatusRejected:
out[i] = r
default:
out[i] = Result{ID: r.ID, Status: StatusRetry}
}
}
return out
}
func allRetry(msgs []Message) []Result {
out := make([]Result, len(msgs))
for i, m := range msgs {
out[i] = Result{ID: m.ID, Status: StatusRetry}
}
return out
}