ProofedMesh/services/approval-service/internal/store/memory.go
Paperclip 3a01cc651f approval-service: Phase-1 content-binding approval service (DEV-608)
New services/approval-service with the Session → Preview → Confirm
pipeline required by DEV-606/DEV-608:

* POST /api/v1/sessions starts a Pocket-ID-bound approval session.
* POST /api/v1/approvals/preview renders content server-side, computes
  H_content = sha256(canonical JSON), stores the (nonce, salt, canonical,
  challenge) tuple and returns the human verification digits plus the
  WebAuthn challenge = nonce || H_content.
* POST /api/v1/approvals/confirm verifies the WebAuthn assertion against
  the stored challenge, DSSE-signs the Approval statement via the
  signing-service (key_ref=trust-api/approval-signer-2026-q4), submits
  to the transparency-log, and returns {approval_id, attestation_ref,
  log_index}. TLog failures leave the row at attestation-pending for
  the reconciler.

* db/trust/migrations/0004_approval_sessions.{up,down}.sql: new tables
  approval_sessions, approval_previews, approvals (schema matches the
  Phase 1 store contract).

* internal/content: canonical JSON + H_content + 6-digit verification
  code (byte-compatible with signing-service/internal/canonical).
* internal/webauthn: stdlib-only Phase-1 verifier; wire shape stays
  WebAuthn-compatible for the Phase-2 swap to go-webauthn/webauthn.
* internal/signer, internal/tlog: HTTP clients mapping 5xx / connection
  errors to typed ErrSignerUnavailable / ErrTLogUnavailable so the
  HTTP layer can return 502 for signing failures and persist
  attestation-pending on TLog failure.
* internal/service.Orchestrator ties the pipeline together and persists
  the Approval BEFORE the TLog submit (never return a log_index for
  an un-persisted approval).

Tests cover: happy path, tampered H_content → 400, expired challenge
→ 403, signer 5xx + conn-refused → 502, TLog failure → row stays
attestation-pending, duplicate confirm → 409, session expired → 403.
go vet clean, go test ./... passes.

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

136 lines
3.1 KiB
Go

package store
import (
"context"
"sync"
"time"
)
// Memory is the Phase-1 in-process Store. Thread-safe via a single RWMutex;
// each call is cheap enough that per-table locks would be over-engineering.
type Memory struct {
mu sync.RWMutex
sessions map[string]Session
previews map[string]Preview
approvals map[string]Approval
}
// NewMemory returns an empty Memory store.
func NewMemory() *Memory {
return &Memory{
sessions: map[string]Session{},
previews: map[string]Preview{},
approvals: map[string]Approval{},
}
}
func (m *Memory) CreateSession(_ context.Context, s Session) error {
m.mu.Lock()
defer m.mu.Unlock()
m.sessions[s.ID] = s
return nil
}
func (m *Memory) GetSession(_ context.Context, id string) (Session, error) {
m.mu.RLock()
defer m.mu.RUnlock()
s, ok := m.sessions[id]
if !ok {
return Session{}, ErrNotFound
}
return s, nil
}
func (m *Memory) SavePreview(_ context.Context, p Preview) error {
m.mu.Lock()
defer m.mu.Unlock()
m.previews[p.ID] = p
return nil
}
func (m *Memory) GetPreview(_ context.Context, id string) (Preview, error) {
m.mu.RLock()
defer m.mu.RUnlock()
p, ok := m.previews[id]
if !ok {
return Preview{}, ErrNotFound
}
// Return a copy so callers cannot mutate our byte slices.
return clonePreview(p), nil
}
// ConsumePreview marks the preview as consumed if it is still valid and
// unconsumed. The returned Preview is the pre-mutation copy.
func (m *Memory) ConsumePreview(_ context.Context, id string, now time.Time) (Preview, error) {
m.mu.Lock()
defer m.mu.Unlock()
p, ok := m.previews[id]
if !ok {
return Preview{}, ErrNotFound
}
if p.ConsumedAt != nil {
return Preview{}, ErrAlreadyConsumed
}
snapshot := clonePreview(p)
ts := now
p.ConsumedAt = &ts
m.previews[id] = p
return snapshot, nil
}
func (m *Memory) SaveApproval(_ context.Context, a Approval) error {
m.mu.Lock()
defer m.mu.Unlock()
m.approvals[a.ID] = a
return nil
}
func (m *Memory) MarkApprovalLogged(_ context.Context, id string, logIndex int64, loggedAt time.Time) error {
m.mu.Lock()
defer m.mu.Unlock()
a, ok := m.approvals[id]
if !ok {
return ErrNotFound
}
a.TLogStatus = TLogLogged
a.LogIndex = &logIndex
a.LoggedAt = &loggedAt
a.TLogError = ""
m.approvals[id] = a
return nil
}
func (m *Memory) MarkApprovalTLogError(_ context.Context, id string, errorMsg string) error {
m.mu.Lock()
defer m.mu.Unlock()
a, ok := m.approvals[id]
if !ok {
return ErrNotFound
}
// Keep status at attestation-pending — a reconciler will retry. We
// only record the last error message for diagnostics.
a.TLogError = errorMsg
m.approvals[id] = a
return nil
}
// GetApproval is a test-side accessor; not part of the Store interface
// because the HTTP handlers never need it.
func (m *Memory) GetApproval(id string) (Approval, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
a, ok := m.approvals[id]
return a, ok
}
func clonePreview(p Preview) Preview {
out := p
out.ContentCanonical = append([]byte(nil), p.ContentCanonical...)
out.Salt = append([]byte(nil), p.Salt...)
out.Nonce = append([]byte(nil), p.Nonce...)
if p.ConsumedAt != nil {
t := *p.ConsumedAt
out.ConsumedAt = &t
}
return out
}