ProofedMesh/services/attest-reconciler/internal/tlog/submitter.go
CTO 2e44147a71
All checks were successful
validate / JSON Schema (ajv) (push) Successful in 19s
validate / Go (vet + test) (push) Successful in 19s
validate / Rego (opa fmt + opa test) (push) Successful in 14s
services: add attest-reconciler Go service (periodic worker re-signs per_diem_rates rows without rate_change_attestation_ref via Signer+TLog, 11/11 go tests pass, go vet clean)
- services/attest-reconciler/ as its own go.mod (admin-api internal/ is not importable; shared code gets extracted when a 3rd consumer appears).
- Scheduler accepts "@every <dur>" or a bare Go duration; context-cancellation stops the loop cleanly.
- Store client defines PendingRateStore + Postgres skeleton for SELECT ... WHERE rate_change_attestation_ref IS NULL.
- Orchestrator + HTTPSigner + tlog.HTTPSubmitter mirror admin-api byte-for-byte so the TLog idempotency key collides on resubmits (kein Doppelsignieren).
- Prometheus text-format /metrics endpoint, dependency-free: attest_reconciler_dirty_rows_total, _reconciliation_attempts_total, _reconciliation_failures_total, _idempotent_noop_total.
- Tests with fake store + fake signer + fake submitter cover: happy path, already-attested rows are NOT re-signed, TLog idempotency across ticks, TLog-down failure path, pre-cancelled ctx.
- Top-level README reflects the new service; CI auto-discovers services/*/go.mod so no workflow change needed.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-25 18:06:36 +00:00

77 lines
2.3 KiB
Go

// Package tlog is the attest-reconciler's HTTP client for the Transparency Log
// (ADR-0005). Mirrors admin-api/internal/tlog.HTTPSubmitter byte-for-byte so
// resubmits from the reconciler collide with the admin-api's original log
// entry — the server's idempotency key covers both paths.
package tlog
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"time"
"basicstack.de/proofedmesh/services/attest-reconciler/internal/attest"
)
type HTTPSubmitter struct {
BaseURL string
Client *http.Client
TokenSource attest.TokenSource
}
type submitRequest struct {
Envelope attest.Envelope `json:"envelope"`
ContentHash string `json:"content_hash"`
}
func (s HTTPSubmitter) Submit(ctx context.Context, env attest.Envelope, contentHash string) (attest.LogReceipt, error) {
if s.BaseURL == "" {
return attest.LogReceipt{}, errors.New("tlog.HTTPSubmitter: BaseURL is empty")
}
cli := s.Client
if cli == nil {
cli = &http.Client{Timeout: 10 * time.Second}
}
body, err := json.Marshal(submitRequest{Envelope: env, ContentHash: contentHash})
if err != nil {
return attest.LogReceipt{}, fmt.Errorf("marshal: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.BaseURL+"/api/v1/log/entries", bytes.NewReader(body))
if err != nil {
return attest.LogReceipt{}, err
}
req.Header.Set("Content-Type", "application/json")
if s.TokenSource != nil {
tok, err := s.TokenSource.Token(ctx)
if err != nil {
return attest.LogReceipt{}, fmt.Errorf("token: %w", err)
}
req.Header.Set("Authorization", "Bearer "+tok)
}
resp, err := cli.Do(req)
if err != nil {
return attest.LogReceipt{}, err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
switch resp.StatusCode {
case http.StatusOK, http.StatusCreated:
default:
return attest.LogReceipt{}, fmt.Errorf("tlog: status %d: %s", resp.StatusCode, string(raw))
}
var out attest.LogReceipt
if err := json.Unmarshal(raw, &out); err != nil {
return attest.LogReceipt{}, fmt.Errorf("decode: %w", err)
}
if out.ContentHash != contentHash {
return out, fmt.Errorf("tlog response content_hash %q != sent %q", out.ContentHash, contentHash)
}
if out.LogIndex < 0 {
return out, fmt.Errorf("tlog response has negative log_index %d", out.LogIndex)
}
return out, nil
}