transparency-log: add GET /api/v1/log/entries/{hash} + /healthz (DEV-613)

The verification-service and testagent-transform clients both call
`GET /api/v1/log/entries/{content_hash}` to read a stored envelope and
inclusion proof back out of the log, but the server previously only
implemented the POST (write) half of ADR-0005. Add:

- `Backend.Get` plus `backend.Entry` and `ErrEntryNotFound` so the
  memory backend can serve the read; Trillian stays stubbed and
  continues returning 503 (`ErrTrillianUnavailable`) until a later
  subtask wires the real read path.
- `GET /api/v1/log/entries/{hash}` on the HTTP server, routing 404 /
  503 / 500 with the same error envelope as the POST half.
- `GET /healthz` so the Phase-1 smoke-test harness can wait for the
  service to come up instead of racing against its bind.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
CTO (Paperclip) 2026-10-25 20:57:52 +00:00
parent 0ae29ae342
commit 47a7373ef2
4 changed files with 105 additions and 10 deletions

View file

@ -18,19 +18,21 @@ import (
// originally assigned log_index with a fresh inclusion proof against the // originally assigned log_index with a fresh inclusion proof against the
// current tree size. // current tree size.
type Memory struct { type Memory struct {
mu sync.Mutex mu sync.Mutex
leaves [][]byte // ordered list of leaf hashes leaves [][]byte // ordered list of leaf hashes
indexByHash map[string]int64 // contentHash -> log_index (zero-based) indexByHash map[string]int64 // contentHash -> log_index (zero-based)
loggedAtByIndex map[int64]time.Time // original append time per index loggedAtByIndex map[int64]time.Time // original append time per index
now func() time.Time envelopesByIndex map[int64]Envelope // original envelope per index (for Get)
now func() time.Time
} }
// NewMemory returns an empty in-memory backend. // NewMemory returns an empty in-memory backend.
func NewMemory() *Memory { func NewMemory() *Memory {
return &Memory{ return &Memory{
indexByHash: map[string]int64{}, indexByHash: map[string]int64{},
loggedAtByIndex: map[int64]time.Time{}, loggedAtByIndex: map[int64]time.Time{},
now: func() time.Time { return time.Now().UTC() }, envelopesByIndex: map[int64]Envelope{},
now: func() time.Time { return time.Now().UTC() },
} }
} }
@ -73,6 +75,7 @@ func (m *Memory) Submit(_ context.Context, env Envelope, contentHash string) (Re
m.leaves = append(m.leaves, leafHash(canonical)) m.leaves = append(m.leaves, leafHash(canonical))
m.indexByHash[contentHash] = idx m.indexByHash[contentHash] = idx
m.loggedAtByIndex[idx] = m.now() m.loggedAtByIndex[idx] = m.now()
m.envelopesByIndex[idx] = env
fresh = true fresh = true
} }
@ -99,6 +102,40 @@ func (m *Memory) Submit(_ context.Context, env Envelope, contentHash string) (Re
}, fresh, nil }, fresh, nil
} }
// Get returns the stored envelope + a fresh inclusion proof against the
// current tree. Suitable for the ADR-0005 read endpoint.
func (m *Memory) Get(_ context.Context, contentHash string) (Entry, error) {
if contentHash == "" {
return Entry{}, errors.New("transparency-log: empty content_hash")
}
m.mu.Lock()
defer m.mu.Unlock()
idx, ok := m.indexByHash[contentHash]
if !ok {
return Entry{}, ErrEntryNotFound
}
env := m.envelopesByIndex[idx]
root := merkleTreeHash(m.leaves)
proof := auditPath(m.leaves, int(idx))
hashes := make([]string, 0, len(proof))
for _, h := range proof {
hashes = append(hashes, sha256Hex(h))
}
return Entry{
ContentHash: contentHash,
Envelope: env,
LogIndex: idx,
LoggedAt: m.loggedAtByIndex[idx],
InclusionProof: InclusionProof{
TreeSize: int64(len(m.leaves)),
TreeRootHash: sha256Hex(root),
SignedTreeHeadRef: signedTreeHeadRef(int64(len(m.leaves)), root),
Hashes: hashes,
},
}, nil
}
// signedTreeHeadRef is a stand-in for the real STH reference. In the // signedTreeHeadRef is a stand-in for the real STH reference. In the
// in-memory backend we deterministically derive it from (tree_size, root), // in-memory backend we deterministically derive it from (tree_size, root),
// so callers get a stable per-head identifier without needing a signer. // so callers get a stable per-head identifier without needing a signer.

View file

@ -58,6 +58,13 @@ type Trillian struct {
dialErr error dialErr error
} }
// Get is not yet wired against Trillian; the production read path is a
// future subtask. The handler short-circuits to 503 when the client is
// absent, so this stub only has to satisfy the interface.
func (t *Trillian) Get(_ context.Context, _ string) (Entry, error) {
return Entry{}, ErrTrillianUnavailable
}
// Submit queues the envelope and returns the inclusion proof against the // Submit queues the envelope and returns the inclusion proof against the
// current signed tree head. See the Backend interface for the fresh // current signed tree head. See the Backend interface for the fresh
// semantics. // semantics.

View file

@ -47,6 +47,27 @@ type InclusionProof struct {
// concurrent use and MUST be idempotent on contentHash: a repeated submit // concurrent use and MUST be idempotent on contentHash: a repeated submit
// with the same contentHash returns the originally assigned log_index and // with the same contentHash returns the originally assigned log_index and
// a valid inclusion proof against the current tree size, and sets fresh=false. // a valid inclusion proof against the current tree size, and sets fresh=false.
//
// Get returns the stored envelope + a fresh inclusion proof against the
// current tree size. Verifiers rely on this to independently recompute the
// Merkle tree root (ADR-0005 §Read side).
type Backend interface { type Backend interface {
Submit(ctx context.Context, env Envelope, contentHash string) (receipt Receipt, fresh bool, err error) Submit(ctx context.Context, env Envelope, contentHash string) (receipt Receipt, fresh bool, err error)
Get(ctx context.Context, contentHash string) (Entry, error)
} }
// Entry is the shape returned by GET /api/v1/log/entries/{content_hash}.
type Entry struct {
ContentHash string `json:"content_hash"`
Envelope Envelope `json:"envelope"`
LogIndex int64 `json:"log_index"`
LoggedAt time.Time `json:"logged_at"`
InclusionProof InclusionProof `json:"inclusion_proof"`
}
// ErrEntryNotFound is returned by Get when contentHash is not in the log.
var ErrEntryNotFound = errorString("transparency-log: entry not found")
type errorString string
func (e errorString) Error() string { return string(e) }

View file

@ -22,14 +22,44 @@ type Server struct {
LogError func(string, ...any) LogError func(string, ...any)
} }
// Handler returns an http.Handler that serves the ADR-0005 routes. Only // Handler returns an http.Handler that serves the ADR-0005 routes plus a
// POST /api/v1/log/entries is implemented; everything else is 404. // liveness check at GET /healthz. Everything else is 404.
func (s *Server) Handler() http.Handler { func (s *Server) Handler() http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.Handle("POST /api/v1/log/entries", http.HandlerFunc(s.handleSubmit)) mux.Handle("POST /api/v1/log/entries", http.HandlerFunc(s.handleSubmit))
mux.Handle("GET /api/v1/log/entries/{hash}", http.HandlerFunc(s.handleGetEntry))
mux.Handle("GET /healthz", http.HandlerFunc(s.handleHealth))
return mux return mux
} }
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"ok"}`))
}
func (s *Server) handleGetEntry(w http.ResponseWriter, r *http.Request) {
hash := r.PathValue("hash")
if hash == "" {
writeError(w, http.StatusBadRequest, "content_hash is required")
return
}
entry, err := s.Backend.Get(r.Context(), hash)
switch {
case errors.Is(err, backend.ErrEntryNotFound):
writeError(w, http.StatusNotFound, "entry not found")
return
case errors.Is(err, backend.ErrTrillianUnavailable):
writeError(w, http.StatusServiceUnavailable, err.Error())
return
case err != nil:
s.logErr("transparency-log get: %v", err)
writeError(w, http.StatusInternalServerError, "internal error")
return
}
writeJSON(w, http.StatusOK, entry)
}
type submitRequest struct { type submitRequest struct {
Envelope backend.Envelope `json:"envelope"` Envelope backend.Envelope `json:"envelope"`
ContentHash string `json:"content_hash"` ContentHash string `json:"content_hash"`