The escrow wizard sends the typed-back words to the box; the new package reaches the hub only when they match (R-1151 controller half, unreleased; works with every agent)
gates / gates (push) Successful in 47s

This commit is contained in:
2026-10-19 03:19:02 +02:00
parent 8ae9178138
commit ba4a6f060b
12 changed files with 434 additions and 35 deletions
+23
View File
@@ -1,3 +1,26 @@
## Unreleased (2026-10-19) — the new recovery code replaces the old one only after the household typed it back (R-1151, controller half)
Ships in the next release. **No new MinAgent:** it works with every agent — the agent's claim answer says whether a
confirm is needed.
- **The wizard's typed-back words now go to the box, not only to the browser.** The claim asks the agent to keep the
new package (`{"defer_upload": true}`); an agent ≥ 0.160.0 answers with `confirm_required` and the two word
positions (it draws them now; the page drew them before), and the page's „Szavak ellenőrzése" posts the two words to
the new `POST /api/escrow/confirm` (session + CSRF, like the other `/api/escrow/*` calls), which hands them to the
agent's `POST /escrow/ceremony/confirm`. Only a match stores the new package on the hub; until then the ceremony is not
complete (no „completed" stamp, no report push — both moved from the claim to the confirm). Answers: wrong words →
422, the page says so and the wait goes on; the agent's window ran out (410) or nothing waits any more (404 — a new
start, an agent restart) → 410 with `restart`, the page clears the code and offers „Újraindítás"; a hub failure →
502, try again. Every answer says the previous code still works where it does. New keys
`api.escrow.confirm_expired`, `confirm_lost`, `confirm_store_failed`, `confirm_words_mismatch` (hu + en).
**An agent older than 0.160.0** ignores the claim's body and stores the package at the claim, as before; its answer
has no `confirm_required`, so the page keeps its own check and the claim completes the ceremony (pinned).
Tests `TestR1151_ClaimThatWaitsIsNotCompleteUntilTheWordsComeBack`, `TestR1151_OldAgentClaimCompletesAtOnce`,
`TestR1151_ConfirmAnswersInTheBoxLanguage`, `TestEscrowClaimAndConfirm_R1151` (agentapi). Red-proved: the
`ConfirmRequired` branch removed → `claim: 200 {"data":{"recovery_code":…}}` with no confirm request. i18n fixtures
changed (named): `escrow_first`, `escrow_receremony` (the page's script and the hidden „Újraindítás" link); every
other fixture byte-identical.
## v0.331.0 — OnlyOffice opens and SAVES from FileBrowser and Nextcloud; FileBrowser is back at once after a restart (R-1018); a removal deletes a volume the definition dropped (R-1117) (2026-10-18)
**MinAgent: 0.158.1** — the floor's, carried (not above the vouched agent 0.159.2). Nothing here calls a new agent route.
+41 -9
View File
@@ -92,31 +92,63 @@ func (c *Client) EscrowCeremonyStatus(ctx context.Context) (EscrowCeremonyStatus
return out, nil
}
// EscrowClaim is a claimed recovery code. ConfirmRequired (agent ≥ 0.160.0, R-1151): the code's package reaches the
// hub only when EscrowCeremonyConfirm brings back the words at Positions (1-based); an older agent answers without
// it — its package is already on the hub, and no confirm is needed.
type EscrowClaim struct {
Code string
ConfirmRequired bool
Positions []int
}
// 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{}{})
//
// R-1151: the claim asks the agent to keep the new package until the household typed the code back
// (`defer_upload`). An agent older than 0.160.0 ignores the body and uploads as before.
func (c *Client) EscrowCeremonyClaim(ctx context.Context) (EscrowClaim, int, error) {
env, status, err := c.postWithStatus(ctx, "/escrow/ceremony/claim", map[string]bool{"defer_upload": true})
if err != nil {
return "", status, err
return EscrowClaim{}, status, err
}
if status == http.StatusGone {
return "", status, fmt.Errorf("agentapi: POST /escrow/ceremony/claim: gone (claimed or expired)")
return EscrowClaim{}, 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
return EscrowClaim{}, status, err
}
var out struct {
RecoveryCode string `json:"recovery_code"`
RecoveryCode string `json:"recovery_code"`
ConfirmRequired bool `json:"confirm_required"`
VerifyPositions []int `json:"verify_positions"`
}
if err := json.Unmarshal(env.Data, &out); err != nil {
return "", status, fmt.Errorf("agentapi: decode /escrow/ceremony/claim: %w", err)
return EscrowClaim{}, 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 EscrowClaim{}, status, fmt.Errorf("agentapi: /escrow/ceremony/claim returned no code")
}
return out.RecoveryCode, status, nil
if out.ConfirmRequired && len(out.VerifyPositions) != 2 {
return EscrowClaim{}, status, fmt.Errorf("agentapi: /escrow/ceremony/claim asks for a confirmation without two positions")
}
return EscrowClaim{Code: out.RecoveryCode, ConfirmRequired: out.ConfirmRequired, Positions: out.VerifyPositions}, status, nil
}
// EscrowCeremonyConfirm sends the two words the household typed back (R-1151, agent ≥ 0.160.0); on success the agent
// has stored the new package on the hub. The status is returned for the caller to classify: 422 the words do not
// match (still waiting), 410 the window ran out, 404 nothing waits (superseded, restarted), 502 the hub failed (still
// waiting). The words are part of the recovery code — never logged.
func (c *Client) EscrowCeremonyConfirm(ctx context.Context, words []string) (int, error) {
env, status, err := c.postWithStatus(ctx, "/escrow/ceremony/confirm", map[string][]string{"words": words})
if err != nil {
return status, err
}
if err := refusalError("/escrow/ceremony/confirm", status, env); err != nil {
return status, err
}
return status, nil
}
// RecoverOffsiteRepoPassword asks the agent to open this host's hub-held sealed bundle with the
@@ -0,0 +1,63 @@
package agentapi
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// R-1151 — the claim asks the agent to keep the package until the words come back, and reads the agent's answer;
// the confirm carries the two words. An agent older than 0.160.0 answers without confirm_required.
func TestEscrowClaimAndConfirm_R1151(t *testing.T) {
var claimBody map[string]any
var confirmBody map[string][]string
newAgent := true
mux := http.NewServeMux()
mux.HandleFunc("POST /escrow/ceremony/claim", func(w http.ResponseWriter, r *http.Request) {
claimBody = nil
_ = json.NewDecoder(r.Body).Decode(&claimBody)
if newAgent {
_, _ = w.Write([]byte(`{"ok":true,"data":{"recovery_code":"a-b-c","confirm_required":true,"verify_positions":[1,3]}}`))
return
}
_, _ = w.Write([]byte(`{"ok":true,"data":{"recovery_code":"a-b-c"}}`))
})
mux.HandleFunc("POST /escrow/ceremony/confirm", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewDecoder(r.Body).Decode(&confirmBody)
if confirmBody["words"][0] != "a" {
w.WriteHeader(http.StatusUnprocessableEntity)
_, _ = w.Write([]byte(`{"ok":false,"error":"the words do not match the recovery code"}`))
return
}
_, _ = w.Write([]byte(`{"ok":true,"data":{"uploaded":true}}`))
})
s := httptest.NewTLSServer(mux)
defer s.Close()
c := clientFor(t, s, strings.TrimPrefix(s.URL, "https://"))
claim, status, err := c.EscrowCeremonyClaim(context.Background())
if err != nil || status != http.StatusOK || claim.Code != "a-b-c" || !claim.ConfirmRequired || len(claim.Positions) != 2 || claim.Positions[1] != 3 {
t.Fatalf("claim: %+v %d %v", claim, status, err)
}
if claimBody["defer_upload"] != true {
t.Fatalf("the claim did not ask the agent to wait for the words: body %v", claimBody)
}
if st, err := c.EscrowCeremonyConfirm(context.Background(), []string{"a", "c"}); err != nil || st != http.StatusOK {
t.Fatalf("confirm: %d %v", st, err)
}
if strings.Join(confirmBody["words"], ",") != "a,c" {
t.Fatalf("confirm sent %v", confirmBody)
}
if st, err := c.EscrowCeremonyConfirm(context.Background(), []string{"x", "c"}); err == nil || st != http.StatusUnprocessableEntity {
t.Fatalf("a wrong word must come back as a 422 error: %d %v", st, err)
}
newAgent = false // agent 0.159.2: no confirm request
claim, _, err = c.EscrowCeremonyClaim(context.Background())
if err != nil || claim.ConfirmRequired || claim.Code != "a-b-c" {
t.Fatalf("claim on an old agent: %+v %v", claim, err)
}
}
+4
View File
@@ -2634,6 +2634,10 @@
"api.escrow.already_running": "A code is already being created — wait until it finishes.",
"api.escrow.code_not_shown": "The code was created but not shown — for security it cannot be fetched again. Start the process again: the new code replaces the old one.",
"api.escrow.no_active_job": "There is no active recovery process — start creating the code first.",
"api.escrow.confirm_expired": "The time to type the new code back has run out — your previous code still works. Start the process again.",
"api.escrow.confirm_lost": "The new code no longer waits for confirmation — your previous code still works. Start the process again.",
"api.escrow.confirm_store_failed": "The new code could not be saved — your previous code still works. Try again.",
"api.escrow.confirm_words_mismatch": "The words do not match — check the code you wrote down.",
"api.share.form_invalid": "Invalid form — reload the page.",
"api.share.too_many_attempts": "Too many failed attempts, try again in 1 minute",
"api.export.upload_size_mismatch": "The uploaded size does not match — try again.",
+4
View File
@@ -2622,6 +2622,10 @@
"api.escrow.already_running": "Egy kódkészítés már folyamatban van — várd meg, míg befejeződik.",
"api.escrow.code_not_shown": "A kód létrejött, de nem lett megjelenítve — biztonsági okból újra nem kérhető le. Indítsd újra a folyamatot: az új kód a régit érvényteleníti.",
"api.escrow.no_active_job": "Nincs aktív helyreállítási folyamat — előbb indítsd el a kódkészítést.",
"api.escrow.confirm_expired": "Lejárt az idő az új kód visszaírására — a régi kódod továbbra is érvényes. Indítsd újra a folyamatot.",
"api.escrow.confirm_lost": "Az új kód már nem vár megerősítésre — a régi kódod továbbra is érvényes. Indítsd újra a folyamatot.",
"api.escrow.confirm_store_failed": "Az új kódot nem sikerült elmenteni — a régi kódod továbbra is érvényes. Próbáld újra.",
"api.escrow.confirm_words_mismatch": "A megadott szavak nem egyeznek — ellenőrizd a felírt kódot.",
"api.share.form_invalid": "Érvénytelen űrlap — töltsd újra az oldalt.",
"api.share.too_many_attempts": "Túl sok sikertelen próbálkozás, próbáld újra 1 perc múlva",
"api.export.upload_size_mismatch": "A feltöltött méret nem egyezik — próbáld újra.",
+62 -5
View File
@@ -32,7 +32,8 @@ type escrowAgent interface {
EscrowPreflight(ctx context.Context) (agentapi.EscrowPreflightResponse, error)
EscrowCeremonyStart(ctx context.Context) (agentapi.EscrowCeremonyStartResponse, int, error)
EscrowCeremonyStatus(ctx context.Context) (agentapi.EscrowCeremonyStatusResponse, error)
EscrowCeremonyClaim(ctx context.Context) (string, int, error)
EscrowCeremonyClaim(ctx context.Context) (agentapi.EscrowClaim, int, error)
EscrowCeremonyConfirm(ctx context.Context, words []string) (int, error)
AgentVersion() string
}
@@ -102,6 +103,8 @@ func (s *Server) ServeEscrowAPI(w http.ResponseWriter, r *http.Request) {
s.escrowStatusAPIHandler(w, r)
case r.URL.Path == "/api/escrow/claim" && r.Method == http.MethodPost:
s.escrowClaimAPIHandler(w, r)
case r.URL.Path == "/api/escrow/confirm" && r.Method == http.MethodPost:
s.escrowConfirmAPIHandler(w, r)
default:
escrowJSON(w, http.StatusNotFound, nil, "ismeretlen végpont")
}
@@ -275,7 +278,8 @@ func (s *Server) escrowClaimAPIHandler(w http.ResponseWriter, r *http.Request) {
escrowJSON(w, http.StatusBadGateway, nil, "Az ügynök nem elérhető.")
return
}
code, status, err := agent.EscrowCeremonyClaim(r.Context())
claim, status, err := agent.EscrowCeremonyClaim(r.Context())
code := claim.Code
if err != nil {
switch status {
case http.StatusGone: // 410 — the code was minted but never shown; permanently void.
@@ -293,7 +297,63 @@ func (s *Server) escrowClaimAPIHandler(w http.ResponseWriter, r *http.Request) {
}
return
}
if claim.ConfirmRequired {
// R-1151: the agent keeps the new package until the household types the two words back (/api/escrow/confirm);
// the ceremony is not complete yet, so nothing is stamped and no report is pushed here.
s.logger.Printf("[INFO] [web] escrow recovery code claimed (one-shot; not logged) — its package waits for the typed-back words")
escrowJSON(w, http.StatusOK, map[string]any{"recovery_code": code, "confirm_required": true, "verify_positions": claim.Positions}, "")
code, claim = "", agentapi.EscrowClaim{}
_, _ = code, claim
return
}
s.logger.Printf("[INFO] [web] escrow recovery code claimed (one-shot; not logged)")
s.escrowCompleted()
escrowJSON(w, http.StatusOK, map[string]any{"recovery_code": code}, "")
code = "" // drop the reference promptly (GC caveat: best-effort)
_ = code
}
// escrowConfirmAPIHandler (R-1151) takes the two words the household typed back and hands them to the agent, which
// stores the new package on the hub only when they match. Body: JSON {"words": [w1, w2]}. The words are part of the
// code: never logged. On success the ceremony is complete (escrowCompleted).
func (s *Server) escrowConfirmAPIHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
var req struct {
Words []string `json:"words"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req); err != nil || len(req.Words) != 2 {
escrowJSON(w, http.StatusBadRequest, nil, s.msg(r, "api.escrow.confirm_words_mismatch"))
return
}
agent, err := s.escrowAgentConn()
if err != nil {
escrowJSON(w, http.StatusBadGateway, nil, "Az ügynök nem elérhető.")
return
}
status, err := agent.EscrowCeremonyConfirm(r.Context(), req.Words)
req.Words = nil
if err != nil {
switch status {
case http.StatusUnprocessableEntity, http.StatusBadRequest: // the words do not match — the wait goes on
escrowJSON(w, http.StatusUnprocessableEntity, nil, s.msg(r, "api.escrow.confirm_words_mismatch"))
case http.StatusGone:
escrowJSON(w, http.StatusGone, map[string]any{"restart": true}, s.msg(r, "api.escrow.confirm_expired"))
case http.StatusNotFound:
escrowJSON(w, http.StatusGone, map[string]any{"restart": true}, s.msg(r, "api.escrow.confirm_lost"))
default:
s.logger.Printf("[WARN] [web] escrow confirm failed (status %d)", status)
escrowJSON(w, http.StatusBadGateway, nil, s.msg(r, "api.escrow.confirm_store_failed"))
}
return
}
s.logger.Printf("[INFO] [web] escrow recovery code typed back — the new package is on the hub (R-1151)")
s.escrowCompleted()
escrowJSON(w, http.StatusOK, map[string]any{"uploaded": true}, "")
}
// escrowCompleted runs when the new package is on the hub: at the claim for an agent older than 0.160.0, at the
// typed-back confirm since (R-1151).
func (s *Server) escrowCompleted() {
// v0.138.0: stamp the ceremony-completed time so /backups/remote shows the "awaiting hub
// confirmation" card during the report-cycle gap before the auto-confirmer flips to escrowed
// (Phase-0 verdict A: the yellow "szükséges" card during that ~15-min wait was the real gap).
@@ -310,9 +370,6 @@ func (s *Server) escrowClaimAPIHandler(w http.ResponseWriter, r *http.Request) {
// hub's ACK hash-match flip pending→escrowed in seconds (EscrowAutoConfirmer, unchanged)
// instead of after the next ~15-min cycle. Non-blocking; a failed push degrades to the cycle.
s.reportTriggerNow()
escrowJSON(w, http.StatusOK, map[string]any{"recovery_code": code}, "")
code = "" // drop the reference promptly (GC caveat: best-effort)
_ = code
}
// clearAuthFailures clears the login counter after a right re-auth password — the counter reserveLoginAttempt counts
+14 -3
View File
@@ -35,7 +35,13 @@ type fakeEscrowAgent struct {
claimCode string
claimStatus int
claimErr error
pf agentapi.EscrowPreflightResponse
// R-1151: an agent ≥ 0.160.0 answers the claim with a confirm request; confirmStatus/confirmErr answer the confirm.
claimConfirm bool
claimPos []int
confirmWords []string
confirmStatus int
confirmErr error
pf agentapi.EscrowPreflightResponse
}
func (f *fakeEscrowAgent) EscrowPreflight(context.Context) (agentapi.EscrowPreflightResponse, error) {
@@ -49,9 +55,14 @@ func (f *fakeEscrowAgent) EscrowCeremonyStart(context.Context) (agentapi.EscrowC
func (f *fakeEscrowAgent) EscrowCeremonyStatus(context.Context) (agentapi.EscrowCeremonyStatusResponse, error) {
return f.status, nil
}
func (f *fakeEscrowAgent) EscrowCeremonyClaim(context.Context) (string, int, error) {
func (f *fakeEscrowAgent) EscrowCeremonyClaim(context.Context) (agentapi.EscrowClaim, int, error) {
*f.order = append(*f.order, "claim")
return f.claimCode, f.claimStatus, f.claimErr
return agentapi.EscrowClaim{Code: f.claimCode, ConfirmRequired: f.claimConfirm, Positions: f.claimPos}, f.claimStatus, f.claimErr
}
func (f *fakeEscrowAgent) EscrowCeremonyConfirm(_ context.Context, words []string) (int, error) {
*f.order = append(*f.order, "confirm")
f.confirmWords = append([]string(nil), words...)
return f.confirmStatus, f.confirmErr
}
func (f *fakeEscrowAgent) AgentVersion() string { return f.version }
@@ -0,0 +1,123 @@
package web
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// R-1151 — the wizard's typed-back words reach the agent, which stores the new package only when they match. Until
// then the ceremony is NOT complete: no „completed" stamp, no report push — the hub still holds the previous package.
func postConfirm(t *testing.T, s *Server, lang, body string) *httptest.ResponseRecorder {
t.Helper()
r := httptest.NewRequest("POST", "/api/escrow/confirm?lang="+lang, strings.NewReader(body))
r.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
s.escrowConfirmAPIHandler(w, r)
return w
}
func ceremonyStamped(t *testing.T, h *escrowWizardHarness) bool {
t.Helper()
o := h.sett.GetOffboxTarget()
return o != nil && o.CeremonyCompletedAt != ""
}
// COMPANION RED-PROOF (observed 2026-10-19): drop the `if claim.ConfirmRequired { … return }` block in
// escrowClaimAPIHandler → this fails with `claim: 200 {"data":{"recovery_code":"egy-ketto-harom-negy"},…}` — no
// confirm request reaches the page, and the claim stamps the ceremony completed. Restored.
func TestR1151_ClaimThatWaitsIsNotCompleteUntilTheWordsComeBack(t *testing.T) {
h := newEscrowWizardHarness(t)
h.configureOffbox(t, "pending")
h.agent.claimCode, h.agent.claimStatus = "egy-ketto-harom-negy", http.StatusOK
h.agent.claimConfirm, h.agent.claimPos = true, []int{2, 4}
w := httptest.NewRecorder()
h.s.escrowClaimAPIHandler(w, httptest.NewRequest("POST", "/api/escrow/claim", nil))
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"confirm_required":true`) || !strings.Contains(w.Body.String(), `"verify_positions":[2,4]`) {
t.Fatalf("claim: %d %s", w.Code, w.Body.String())
}
if ceremonyStamped(t, h) {
t.Fatal("a claim that waits for the typed-back words stamped the ceremony completed")
}
h.agent.confirmStatus = http.StatusOK
if w := postConfirm(t, h.s, "hu", `{"words":["ketto","negy"]}`); w.Code != http.StatusOK {
t.Fatalf("confirm: %d %s", w.Code, w.Body.String())
}
if got := strings.Join(h.agent.confirmWords, ","); got != "ketto,negy" {
t.Fatalf("the agent got the words %q, want ketto,negy", got)
}
if !ceremonyStamped(t, h) {
t.Fatal("the confirmed ceremony was not stamped completed")
}
if c := w.Header().Get("Cache-Control"); c != "no-store" {
t.Fatalf("confirm Cache-Control = %q", c)
}
}
// An agent older than 0.160.0 answers the claim without a confirm request: its package is on the hub, and the claim
// completes the ceremony as before.
func TestR1151_OldAgentClaimCompletesAtOnce(t *testing.T) {
h := newEscrowWizardHarness(t)
h.configureOffbox(t, "pending")
h.agent.claimCode, h.agent.claimStatus = "egy-ketto-harom-negy", http.StatusOK
w := httptest.NewRecorder()
h.s.escrowClaimAPIHandler(w, httptest.NewRequest("POST", "/api/escrow/claim", nil))
if w.Code != http.StatusOK || strings.Contains(w.Body.String(), "confirm_required") {
t.Fatalf("claim on an old agent: %d %s", w.Code, w.Body.String())
}
if !ceremonyStamped(t, h) {
t.Fatal("an old agent's claim must complete the ceremony")
}
for _, o := range h.order {
if o == "confirm" {
t.Fatal("a confirm was sent to an old agent")
}
}
}
// Every refusal of the confirm answers in the box's language, says the previous code still works where it does,
// and tells the page to start again only when the new code is void.
func TestR1151_ConfirmAnswersInTheBoxLanguage(t *testing.T) {
cases := []struct {
agent int
want int
restart bool
hu, en string
}{
{http.StatusUnprocessableEntity, http.StatusUnprocessableEntity, false, "A megadott szavak nem egyeznek", "The words do not match"},
{http.StatusGone, http.StatusGone, true, "Lejárt az idő", "has run out"},
{http.StatusNotFound, http.StatusGone, true, "már nem vár megerősítésre", "no longer waits"},
{http.StatusBadGateway, http.StatusBadGateway, false, "nem sikerült elmenteni", "could not be saved"},
{0, http.StatusBadGateway, false, "nem sikerült elmenteni", "could not be saved"},
}
for _, c := range cases {
for _, lang := range []string{"hu", "en"} {
h := newEscrowWizardHarness(t)
h.agent.confirmStatus, h.agent.confirmErr = c.agent, fmt.Errorf("agent said %d", c.agent)
w := postConfirm(t, h.s, lang, `{"words":["a","b"]}`)
want := map[string]string{"hu": c.hu, "en": c.en}[lang]
if w.Code != c.want || !strings.Contains(w.Body.String(), want) {
t.Errorf("agent %d, %s: got %d %s — want %d with %q", c.agent, lang, w.Code, w.Body.String(), c.want, want)
}
if got := strings.Contains(w.Body.String(), `"restart":true`); got != c.restart {
t.Errorf("agent %d: restart=%v, want %v", c.agent, got, c.restart)
}
if ceremonyStamped(t, h) {
t.Errorf("agent %d: a refused confirm stamped the ceremony", c.agent)
}
}
}
// A body without two words never reaches the agent.
h := newEscrowWizardHarness(t)
if w := postConfirm(t, h.s, "hu", `{"words":["a"]}`); w.Code != http.StatusBadRequest {
t.Fatalf("one word: got %d", w.Code)
}
if len(h.order) != 0 {
t.Fatalf("a malformed confirm reached the agent: %v", h.order)
}
}
@@ -98,6 +98,7 @@
<div id="verify-error" class="alert alert-error" style="display:none;margin-top:.5rem">{{T "backups_escrow.a_megadott_szavak_nem_egyeznek"}}</div>
</div>
<button class="btn btn-sm" id="verify-btn" onclick="verifyWords()">{{T "backups_escrow.szavak_ellenorzese"}}</button>
<a href="/backup/escrow" class="btn btn-sm btn-primary" id="verify-restart" style="display:none">{{T "backups_escrow.ujrainditas"}}</a>
<div id="finish-block" style="display:none;margin-top:1rem">
<h3>{{T "backups_escrow.7_befejezes"}}</h3>
{{/* v0.138.0: the "what happens next" note — the wizard finishes to /backups/remote,
@@ -239,6 +240,9 @@ function pollStatus(){
// finishWizard() clears both. It never leaves the page's JS scope.
var claimedR='';
var verifyIdx=[0,0];
// R-1151: an agent ≥ 0.160.0 keeps the new package until the two words come back through /api/escrow/confirm, and
// names the two positions itself; an older agent stored it at the claim, and the page picks the positions.
var confirmRequired=false;
async function claimCode(){
var btn=document.getElementById('reveal-btn'); btn.disabled=true;
@@ -253,9 +257,13 @@ async function claimCode(){
// distinct 1-based word positions of the dash-separated code.
var words=claimedR.split('-');
var n=words.length;
var a=1+Math.floor(Math.random()*n), b=1+Math.floor(Math.random()*n);
while(b===a){ b=1+Math.floor(Math.random()*n); }
verifyIdx=[Math.min(a,b),Math.max(a,b)];
confirmRequired=!!j.data.confirm_required;
if(confirmRequired){ verifyIdx=[j.data.verify_positions[0], j.data.verify_positions[1]]; }
else {
var a=1+Math.floor(Math.random()*n), b=1+Math.floor(Math.random()*n);
while(b===a){ b=1+Math.floor(Math.random()*n); }
verifyIdx=[Math.min(a,b),Math.max(a,b)];
}
// Span-per-word DOM build — createElement + textContent + createTextNode ONLY. R never flows
// through innerHTML/insertAdjacentHTML: with no HTML parse context there is no injection
// surface, the same guarantee the old single textContent assignment gave. The two words the
@@ -288,13 +296,31 @@ function setCodeBlur(on){
}
function toggleCodeBlur(){ setCodeBlur(!codeBlurred); }
function verifyWords(){
async function verifyWords(){
var words=claimedR.split('-');
var w1=(document.getElementById('verify-w1').value||'').trim().toLowerCase();
var w2=(document.getElementById('verify-w2').value||'').trim().toLowerCase();
var ok = w1===words[verifyIdx[0]-1] && w2===words[verifyIdx[1]-1];
document.getElementById('verify-error').style.display=ok?'none':'block';
if(ok){ document.getElementById('finish-block').style.display='block'; }
var errEl=document.getElementById('verify-error');
errEl.textContent='{{T "backups_escrow.a_megadott_szavak_nem_egyeznek"}}';
errEl.style.display=ok?'none':'block';
if(!ok){ return; }
if(confirmRequired){
var btn=document.getElementById('verify-btn'); btn.disabled=true;
try{
var r=await fetch('/api/escrow/confirm',{method:'POST',
headers:Object.assign({'Content-Type':'application/json'},csrfHeaders()),body:JSON.stringify({words:[w1,w2]})});
var j=await r.json();
if(!j.ok){
errEl.textContent=j.error||'{{T "backups_escrow.a_megadott_szavak_nem_egyeznek"}}'; errEl.style.display='block';
if(j.data && j.data.restart){ // the new code is void; the previous one still works — start again
claimedR=''; document.getElementById('code-block').textContent='';
btn.style.display='none'; document.getElementById('verify-restart').style.display='inline-block'; return; }
btn.disabled=false; return;
}
}catch(e){ errEl.textContent='{{T "backups_escrow.hiba_2"}} '+e.message; errEl.style.display='block'; btn.disabled=false; return; }
}
document.getElementById('finish-block').style.display='block';
}
function finishWizard(){
@@ -244,6 +244,7 @@
<div id="verify-error" class="alert alert-error" style="display:none;margin-top:.5rem">A megadott szavak nem egyeznek — ellenőrizd a felírt kódot.</div>
</div>
<button class="btn btn-sm" id="verify-btn" onclick="verifyWords()">Szavak ellenőrzése</button>
<a href="/backup/escrow" class="btn btn-sm btn-primary" id="verify-restart" style="display:none">Újraindítás</a>
<div id="finish-block" style="display:none;margin-top:1rem">
<h3>7. Befejezés</h3>
@@ -385,6 +386,9 @@ function pollStatus(){
var claimedR='';
var verifyIdx=[0,0];
var confirmRequired=false;
async function claimCode(){
var btn=document.getElementById('reveal-btn'); btn.disabled=true;
var errEl=document.getElementById('reveal-error'); errEl.style.display='none';
@@ -398,9 +402,13 @@ async function claimCode(){
var words=claimedR.split('-');
var n=words.length;
var a=1+Math.floor(Math.random()*n), b=1+Math.floor(Math.random()*n);
while(b===a){ b=1+Math.floor(Math.random()*n); }
verifyIdx=[Math.min(a,b),Math.max(a,b)];
confirmRequired=!!j.data.confirm_required;
if(confirmRequired){ verifyIdx=[j.data.verify_positions[0], j.data.verify_positions[1]]; }
else {
var a=1+Math.floor(Math.random()*n), b=1+Math.floor(Math.random()*n);
while(b===a){ b=1+Math.floor(Math.random()*n); }
verifyIdx=[Math.min(a,b),Math.max(a,b)];
}
@@ -433,13 +441,31 @@ function setCodeBlur(on){
}
function toggleCodeBlur(){ setCodeBlur(!codeBlurred); }
function verifyWords(){
async function verifyWords(){
var words=claimedR.split('-');
var w1=(document.getElementById('verify-w1').value||'').trim().toLowerCase();
var w2=(document.getElementById('verify-w2').value||'').trim().toLowerCase();
var ok = w1===words[verifyIdx[0]-1] && w2===words[verifyIdx[1]-1];
document.getElementById('verify-error').style.display=ok?'none':'block';
if(ok){ document.getElementById('finish-block').style.display='block'; }
var errEl=document.getElementById('verify-error');
errEl.textContent='A megadott szavak nem egyeznek — ellenőrizd a felírt kódot.';
errEl.style.display=ok?'none':'block';
if(!ok){ return; }
if(confirmRequired){
var btn=document.getElementById('verify-btn'); btn.disabled=true;
try{
var r=await fetch('/api/escrow/confirm',{method:'POST',
headers:Object.assign({'Content-Type':'application/json'},csrfHeaders()),body:JSON.stringify({words:[w1,w2]})});
var j=await r.json();
if(!j.ok){
errEl.textContent=j.error||'A megadott szavak nem egyeznek — ellenőrizd a felírt kódot.'; errEl.style.display='block';
if(j.data && j.data.restart){
claimedR=''; document.getElementById('code-block').textContent='';
btn.style.display='none'; document.getElementById('verify-restart').style.display='inline-block'; return; }
btn.disabled=false; return;
}
}catch(e){ errEl.textContent='Hiba: '+e.message; errEl.style.display='block'; btn.disabled=false; return; }
}
document.getElementById('finish-block').style.display='block';
}
function finishWizard(){
@@ -249,6 +249,7 @@
<div id="verify-error" class="alert alert-error" style="display:none;margin-top:.5rem">A megadott szavak nem egyeznek — ellenőrizd a felírt kódot.</div>
</div>
<button class="btn btn-sm" id="verify-btn" onclick="verifyWords()">Szavak ellenőrzése</button>
<a href="/backup/escrow" class="btn btn-sm btn-primary" id="verify-restart" style="display:none">Újraindítás</a>
<div id="finish-block" style="display:none;margin-top:1rem">
<h3>7. Befejezés</h3>
@@ -390,6 +391,9 @@ function pollStatus(){
var claimedR='';
var verifyIdx=[0,0];
var confirmRequired=false;
async function claimCode(){
var btn=document.getElementById('reveal-btn'); btn.disabled=true;
var errEl=document.getElementById('reveal-error'); errEl.style.display='none';
@@ -403,9 +407,13 @@ async function claimCode(){
var words=claimedR.split('-');
var n=words.length;
var a=1+Math.floor(Math.random()*n), b=1+Math.floor(Math.random()*n);
while(b===a){ b=1+Math.floor(Math.random()*n); }
verifyIdx=[Math.min(a,b),Math.max(a,b)];
confirmRequired=!!j.data.confirm_required;
if(confirmRequired){ verifyIdx=[j.data.verify_positions[0], j.data.verify_positions[1]]; }
else {
var a=1+Math.floor(Math.random()*n), b=1+Math.floor(Math.random()*n);
while(b===a){ b=1+Math.floor(Math.random()*n); }
verifyIdx=[Math.min(a,b),Math.max(a,b)];
}
@@ -438,13 +446,31 @@ function setCodeBlur(on){
}
function toggleCodeBlur(){ setCodeBlur(!codeBlurred); }
function verifyWords(){
async function verifyWords(){
var words=claimedR.split('-');
var w1=(document.getElementById('verify-w1').value||'').trim().toLowerCase();
var w2=(document.getElementById('verify-w2').value||'').trim().toLowerCase();
var ok = w1===words[verifyIdx[0]-1] && w2===words[verifyIdx[1]-1];
document.getElementById('verify-error').style.display=ok?'none':'block';
if(ok){ document.getElementById('finish-block').style.display='block'; }
var errEl=document.getElementById('verify-error');
errEl.textContent='A megadott szavak nem egyeznek — ellenőrizd a felírt kódot.';
errEl.style.display=ok?'none':'block';
if(!ok){ return; }
if(confirmRequired){
var btn=document.getElementById('verify-btn'); btn.disabled=true;
try{
var r=await fetch('/api/escrow/confirm',{method:'POST',
headers:Object.assign({'Content-Type':'application/json'},csrfHeaders()),body:JSON.stringify({words:[w1,w2]})});
var j=await r.json();
if(!j.ok){
errEl.textContent=j.error||'A megadott szavak nem egyeznek — ellenőrizd a felírt kódot.'; errEl.style.display='block';
if(j.data && j.data.restart){
claimedR=''; document.getElementById('code-block').textContent='';
btn.style.display='none'; document.getElementById('verify-restart').style.display='inline-block'; return; }
btn.disabled=false; return;
}
}catch(e){ errEl.textContent='Hiba: '+e.message; errEl.style.display='block'; btn.disabled=false; return; }
}
document.getElementById('finish-block').style.display='block';
}
function finishWizard(){
+4
View File
@@ -1,6 +1,10 @@
{
"_comment": "Localisation slice 2 (R-557). key -> the base-commit Go literal it replaced, or the ORDERED list of literals a concatenation joined. Checked by scripts/i18n_go_parity.py against scripts/i18n_go_base.json, which is frozen at 736f54b49610 (the base commit of release v0.252.0). A key whose Hungarian text is not byte-identical to what the Go code said fails the gate.",
"_preexisting": {
"api.escrow.confirm_words_mismatch": "BORN AS A KEY (R-1151, 2026-10-19) -- the confirm API's answer to words that do not match; the same Hungarian as the page's own backups_escrow.a_megadott_szavak_nem_egyeznek (a template key, which Go may not name). Pinned by TestR1151_ConfirmAnswersInTheBoxLanguage.",
"api.escrow.confirm_expired": "BORN AS A KEY (R-1151, 2026-10-19) -- a NEW sentence, never a Go literal: the wizard's typed-back confirm answer (agent >= 0.160.0 keeps the new package until the words come back). Pinned by TestR1151_ConfirmAnswersInTheBoxLanguage.",
"api.escrow.confirm_lost": "BORN AS A KEY (R-1151, 2026-10-19) -- a NEW sentence, never a Go literal: the wizard's typed-back confirm answer (agent >= 0.160.0 keeps the new package until the words come back). Pinned by TestR1151_ConfirmAnswersInTheBoxLanguage.",
"api.escrow.confirm_store_failed": "BORN AS A KEY (R-1151, 2026-10-19) -- a NEW sentence, never a Go literal: the wizard's typed-back confirm answer (agent >= 0.160.0 keeps the new package until the words come back). Pinned by TestR1151_ConfirmAnswersInTheBoxLanguage.",
"waiting.title": "BORN AS A KEY (R-554, 2026-10-12) -- the title of the page an unconfigured box shows while it retries its configuration, replacing the deleted setup wizard. Pinned by internal/bootstrap TestWaitForConfig_ServesWaitingPageThenStarts.",
"waiting.body": "BORN AS A KEY (R-554, 2026-10-12) -- the body of that waiting page. Pinned by internal/bootstrap TestWaitForConfig_ServesWaitingPageThenStarts.",
"recovery_info.body": "BORN AS A KEY (R-554, 2026-10-12, `09` §3 decision 222) -- the NEW text of recovery-info.txt, pointing at the dashboard's Restore page and the recovery code instead of the deleted setup wizard. The operator approves the Hungarian before release. Pinned by internal/recovery TestRecoveryInfo_PointsAtDashboardNotWizard.",