// Package verify calls the verification-service's POST // /api/v1/verify/chain endpoint. Replay protection for `decision_id` lives // in the verification-service — the sink does not keep its own replay // table. See the parent plan subtask 6 for the exact chain checks. package verify import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "time" "basicstack.de/proofedmesh/services/dummy-sink/internal/auth" ) // ErrVerifyUnavailable classifies connection errors and 5xx responses. var ErrVerifyUnavailable = errors.New("verify: verification-service unavailable") // Request is the payload posted to the verification-service. type Request struct { DecisionID string `json:"decision_id"` PayloadHash string `json:"payload_hash"` ApprovalRef string `json:"approval_ref"` TransformRef string `json:"transform_ref"` SinkTarget string `json:"sink_target"` } // Result is the verification-service's structured verdict. The field set // matches the Phase-1 plan: valid, decision_id, actors, reasons, plus a // dedicated `replay` flag so the sink can map replays to 409. type Result struct { Valid bool `json:"valid"` Replay bool `json:"replay"` DecisionID string `json:"decision_id"` Actors []string `json:"actors,omitempty"` Reasons []string `json:"reasons,omitempty"` ApprovalRef string `json:"approval_ref,omitempty"` } // Client calls POST /api/v1/verify/chain. type Client struct { BaseURL string HTTPClient *http.Client TokenSource auth.TokenSource } // Verify asks the verification-service whether the chain (approval -> // transform) authorises writing payloadHash to sinkTarget. func (c Client) Verify(ctx context.Context, req Request) (Result, error) { if c.BaseURL == "" { return Result{}, errors.New("verify.Client: BaseURL is empty") } hc := c.HTTPClient if hc == nil { hc = &http.Client{Timeout: 10 * time.Second} } body, err := json.Marshal(req) if err != nil { return Result{}, fmt.Errorf("verify: marshal: %w", err) } endpoint, err := url.JoinPath(c.BaseURL, "/api/v1/verify/chain") if err != nil { return Result{}, fmt.Errorf("verify: build url: %w", err) } httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) if err != nil { return Result{}, err } httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Accept", "application/json") if c.TokenSource != nil { tok, err := c.TokenSource.Token(ctx) if err != nil { return Result{}, fmt.Errorf("verify: token: %w", err) } if tok != "" { httpReq.Header.Set("Authorization", "Bearer "+tok) } } resp, err := hc.Do(httpReq) if err != nil { return Result{}, fmt.Errorf("%w: %v", ErrVerifyUnavailable, err) } defer resp.Body.Close() raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) switch { case resp.StatusCode == http.StatusOK: // fall through case resp.StatusCode >= 500: return Result{}, fmt.Errorf("%w: status %d: %s", ErrVerifyUnavailable, resp.StatusCode, truncate(raw, 256)) default: return Result{}, fmt.Errorf("verify: status %d: %s", resp.StatusCode, truncate(raw, 256)) } var out Result if err := json.Unmarshal(raw, &out); err != nil { return Result{}, fmt.Errorf("verify: decode: %w", err) } return out, nil } func truncate(b []byte, n int) string { if len(b) <= n { return string(b) } return string(b[:n]) + "…" }