ProofedMesh/services/approval-service/internal/store/store.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

90 lines
3 KiB
Go

// Package store defines the persistence contract for approval_sessions,
// approval_previews, and approvals. Phase 1 ships an in-memory impl; the
// Postgres schema is in db/trust/migrations/0004_approval_sessions.*.sql
// and wired via a skeleton Postgres type (see postgres.go) so the HTTP
// layer stays unchanged when the real pgxpool wiring lands.
package store
import (
"context"
"errors"
"time"
"basicstack.de/proofedmesh/services/approval-service/internal/signer"
)
// ErrNotFound is returned by lookup methods when the row does not exist
// or has expired.
var ErrNotFound = errors.New("store: not found")
// ErrAlreadyConsumed is returned by ConsumePreview when the preview has
// already been consumed — the confirm endpoint MUST reject a replay.
var ErrAlreadyConsumed = errors.New("store: preview already consumed")
// Session is a per-browser-login approval session. active_until bounds
// how long previews may be created against it.
type Session struct {
ID string
UserSub string
ActiveUntil time.Time
CreatedAt time.Time
}
// Preview is a server-rendered approval payload that has not yet been
// confirmed. ContentCanonical lets the confirm endpoint recompute
// H_content deterministically from the stored row.
type Preview struct {
ID string
SessionID string
UserSub string
ContentHash string
ContentCanonical []byte
Salt []byte
Nonce []byte
VerificationDigits string
ExpiresAt time.Time
CreatedAt time.Time
ConsumedAt *time.Time
}
// TLogStatus is the three-valued state of the Transparency-Log submit.
type TLogStatus string
const (
TLogPending TLogStatus = "attestation-pending"
TLogLogged TLogStatus = "logged"
TLogFailed TLogStatus = "failed"
)
// Approval is the confirmed, DSSE-signed approval record. If TLogStatus
// is TLogPending/TLogFailed, log_index is nil and the attest-reconciler
// (or a future worker) will replay the Transparency-Log submit against
// the server's idempotent /api/v1/log/entries contract.
type Approval struct {
ID string
PreviewID string
SessionID string
UserSub string
ContentHash string
Envelope signer.Envelope
AttestationRef string
TLogStatus TLogStatus
LogIndex *int64
TLogError string
ConfirmedAt time.Time
LoggedAt *time.Time
}
// Store is the full persistence surface the HTTP service depends on.
type Store interface {
CreateSession(ctx context.Context, s Session) error
GetSession(ctx context.Context, id string) (Session, error)
SavePreview(ctx context.Context, p Preview) error
GetPreview(ctx context.Context, id string) (Preview, error)
ConsumePreview(ctx context.Context, id string, now time.Time) (Preview, error)
SaveApproval(ctx context.Context, a Approval) error
MarkApprovalLogged(ctx context.Context, id string, logIndex int64, loggedAt time.Time) error
MarkApprovalTLogError(ctx context.Context, id string, errorMsg string) error
}