v0.127.0: customer-facing escrow ceremony wizard (/backup/escrow) + Scenario-F stale-blob re-check — one-shot R reveal (no-store, typed-back), re-stage-first start order, agent version gate (MinAgent 0.88.0), escrowed-state hash re-check with card warning (never flips, never blocks); manual-confirm button removed (endpoint stays deprecated)
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
package agentapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Controller-driven escrow ceremony client methods (v0.127.0, agent ≥ v0.88.0). The claim call
|
||||
// is the ONLY place the recovery code R crosses this client — its response body must never be
|
||||
// logged (the shared helpers log path/status/duration only, never bodies) and the caller hands
|
||||
// R straight to the wizard's claim XHR, nowhere else.
|
||||
|
||||
// EscrowPreflightItem mirrors one agent preflight checklist row.
|
||||
type EscrowPreflightItem struct {
|
||||
ID string `json:"id"`
|
||||
OK bool `json:"ok"`
|
||||
Detail string `json:"detail"`
|
||||
}
|
||||
|
||||
// EscrowPreflightResponse mirrors GET /escrow/preflight.
|
||||
type EscrowPreflightResponse struct {
|
||||
VMID int `json:"vmid"`
|
||||
OK bool `json:"ok"`
|
||||
Items []EscrowPreflightItem `json:"items"`
|
||||
}
|
||||
|
||||
// EscrowPreflight fetches the agent's ceremony prerequisite checklist.
|
||||
func (c *Client) EscrowPreflight(ctx context.Context) (EscrowPreflightResponse, error) {
|
||||
var out EscrowPreflightResponse
|
||||
body, err := c.get(ctx, "/escrow/preflight")
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return out, fmt.Errorf("agentapi: decode /escrow/preflight: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// EscrowCeremonyStartResponse mirrors the POST /escrow/ceremony 202 payload.
|
||||
type EscrowCeremonyStartResponse struct {
|
||||
JobID string `json:"job_id"`
|
||||
Phase string `json:"phase"`
|
||||
}
|
||||
|
||||
// EscrowCeremonyStart triggers the agent's detached root ceremony job. Status-aware: the HTTP
|
||||
// status is returned so the caller can map the agent's 409 (a ceremony already running) to its
|
||||
// own house-style refusal.
|
||||
func (c *Client) EscrowCeremonyStart(ctx context.Context) (EscrowCeremonyStartResponse, int, error) {
|
||||
var out EscrowCeremonyStartResponse
|
||||
env, status, err := c.postWithStatus(ctx, "/escrow/ceremony", struct{}{})
|
||||
if err != nil {
|
||||
return out, status, err
|
||||
}
|
||||
if err := refusalError("/escrow/ceremony", status, env); err != nil {
|
||||
return out, status, err
|
||||
}
|
||||
if err := json.Unmarshal(env.Data, &out); err != nil {
|
||||
return out, status, fmt.Errorf("agentapi: decode /escrow/ceremony: %w", err)
|
||||
}
|
||||
return out, status, nil
|
||||
}
|
||||
|
||||
// EscrowCeremonyStatusResponse mirrors GET /escrow/ceremony/status — the NON-SECRET job view
|
||||
// (R is structurally absent from the agent's payload).
|
||||
type EscrowCeremonyStatusResponse struct {
|
||||
Phase string `json:"phase"` // none | running | done | failed | unclaimed_void
|
||||
JobID string `json:"job_id"`
|
||||
KeyFingerprint string `json:"key_fingerprint"`
|
||||
EntropyBits float64 `json:"entropy_bits"`
|
||||
ResticPwSealed bool `json:"restic_pw_sealed"`
|
||||
Uploaded bool `json:"uploaded"`
|
||||
Claimable bool `json:"claimable"`
|
||||
Claimed bool `json:"claimed"`
|
||||
ClaimExpiresInSec int `json:"claim_expires_in_sec"`
|
||||
Detail string `json:"detail"`
|
||||
}
|
||||
|
||||
// EscrowCeremonyStatus polls the ceremony job.
|
||||
func (c *Client) EscrowCeremonyStatus(ctx context.Context) (EscrowCeremonyStatusResponse, error) {
|
||||
var out EscrowCeremonyStatusResponse
|
||||
body, err := c.get(ctx, "/escrow/ceremony/status")
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return out, fmt.Errorf("agentapi: decode /escrow/ceremony/status: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// EscrowCeremonyClaim performs the ONE-SHOT R claim. Returns the recovery code + the agent's
|
||||
// HTTP status (410 = already claimed / expired — the wizard's void state). The code must never
|
||||
// be logged, persisted, or placed anywhere but the claim XHR response; the error path carries
|
||||
// the agent's reason text, never the code.
|
||||
func (c *Client) EscrowCeremonyClaim(ctx context.Context) (string, int, error) {
|
||||
env, status, err := c.postWithStatus(ctx, "/escrow/ceremony/claim", struct{}{})
|
||||
if err != nil {
|
||||
return "", status, err
|
||||
}
|
||||
if status == http.StatusGone {
|
||||
return "", status, fmt.Errorf("agentapi: POST /escrow/ceremony/claim: gone (claimed or expired)")
|
||||
}
|
||||
if err := refusalError("/escrow/ceremony/claim", status, env); err != nil {
|
||||
return "", status, err
|
||||
}
|
||||
var out struct {
|
||||
RecoveryCode string `json:"recovery_code"`
|
||||
}
|
||||
if err := json.Unmarshal(env.Data, &out); err != nil {
|
||||
return "", status, fmt.Errorf("agentapi: decode /escrow/ceremony/claim: %w", err)
|
||||
}
|
||||
if out.RecoveryCode == "" {
|
||||
return "", status, fmt.Errorf("agentapi: /escrow/ceremony/claim returned no code")
|
||||
}
|
||||
return out.RecoveryCode, status, nil
|
||||
}
|
||||
Reference in New Issue
Block a user