From 7e2ed0bc08c75978d637ad2b7dfd8544fecaf7a7 Mon Sep 17 00:00:00 2001 From: Azat Valiev Date: Thu, 27 Aug 2026 12:54:58 +0000 Subject: [PATCH 1/3] round-trip state_declarations through AI paywall editing --- internal/api/paywalls.go | 8 +- internal/api/paywalls_test.go | 28 ++++++ internal/cli/agents_test.go | 4 + internal/cli/paywalls_ai.go | 22 +++++ internal/cli/paywalls_ai_session_test.go | 109 ++++++++++++++++++++--- internal/cli/paywalls_stream_test.go | 23 +++++ internal/paywallai/paywallai.go | 3 + 7 files changed, 183 insertions(+), 14 deletions(-) diff --git a/internal/api/paywalls.go b/internal/api/paywalls.go index a5566201..7dd3dee4 100644 --- a/internal/api/paywalls.go +++ b/internal/api/paywalls.go @@ -30,6 +30,7 @@ type PaywallComponentsVersion struct { Revision *int `json:"revision"` ComponentsConfig json.RawMessage `json:"components_config"` ComponentsLocalizations json.RawMessage `json:"components_localizations"` + StateDeclarations json.RawMessage `json:"state_declarations,omitempty"` DefaultLocale string `json:"default_locale"` AutomaticallyScaleFontSize bool `json:"automatically_scale_font_size"` } @@ -41,8 +42,11 @@ type PaywallDraftUpdate struct { Revision int `json:"revision"` ComponentsConfig json.RawMessage `json:"components_config"` ComponentsLocalizations json.RawMessage `json:"components_localizations"` - DefaultLocale string `json:"default_locale"` - Name *string `json:"name,omitempty"` + // Unset must marshal as an omitted field, never as null: the server keeps + // stored declarations when the field is absent but clears them on explicit null. + StateDeclarations json.RawMessage `json:"state_declarations,omitempty"` + DefaultLocale string `json:"default_locale"` + Name *string `json:"name,omitempty"` } func (s *PaywallsService) List(ctx context.Context, projectID string) (*Page[Paywall], error) { diff --git a/internal/api/paywalls_test.go b/internal/api/paywalls_test.go index addbbfac..839d1c8e 100644 --- a/internal/api/paywalls_test.go +++ b/internal/api/paywalls_test.go @@ -2,6 +2,7 @@ package api_test import ( "context" + "encoding/json" "io" "net/http" "net/http/httptest" @@ -11,6 +12,33 @@ import ( "github.com/revenuecat/cli/internal/api" ) +// An explicit "state_declarations": null clears the stored declarations +// server-side, so an unset field must be omitted from the PATCH entirely. +func TestPaywallDraftUpdateOmitsUnsetStateDeclarations(t *testing.T) { + update := api.PaywallDraftUpdate{ + Revision: 1, + ComponentsConfig: json.RawMessage(`{}`), + ComponentsLocalizations: json.RawMessage(`{}`), + DefaultLocale: "en_US", + } + unset, err := json.Marshal(update) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(unset), "state_declarations") { + t.Fatalf("unset state_declarations must be omitted, not sent as null: %s", unset) + } + + update.StateDeclarations = json.RawMessage(`{}`) + set, err := json.Marshal(update) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(set), `"state_declarations":{}`) { + t.Fatalf("set state_declarations missing from PATCH body: %s", set) + } +} + func TestPaywallsPublishPreservesPublishedState(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || r.URL.Path != "/projects/proj/paywalls/pw/actions/publish" { diff --git a/internal/cli/agents_test.go b/internal/cli/agents_test.go index e71532a0..59a4dd00 100644 --- a/internal/cli/agents_test.go +++ b/internal/cli/agents_test.go @@ -356,6 +356,10 @@ func TestPaywallsGenerate_CreatesDraftStreamsAndSavesSession(t *testing.T) { if input["include_result_screenshots"] != true { t.Fatalf("include_result_screenshots = %v", input["include_result_screenshots"]) } + // a brand-new paywall sends empty-but-present declarations so the editor may wire new state + if decls, ok := input["paywall"].(map[string]any)["state_declarations"].(map[string]any); !ok || len(decls) != 0 { + t.Fatalf("generate editor request paywall.state_declarations = %v, want {}", input["paywall"]) + } // Session file round-trips the completed paywall + opaque blobs. payload, err := os.ReadFile(sessionPath) diff --git a/internal/cli/paywalls_ai.go b/internal/cli/paywalls_ai.go index 8f8bb9ca..2c9ec99b 100644 --- a/internal/cli/paywalls_ai.go +++ b/internal/cli/paywalls_ai.go @@ -220,6 +220,7 @@ screenshots via --image, audience via --context.`, OfferingID: offeringID, ComponentsConfig: json.RawMessage(minimalComponentsConfig), ComponentsLocalizations: json.RawMessage(`{"en_US": {}}`), + StateDeclarations: json.RawMessage(`{}`), }, UIConfig: json.RawMessage(minimalUIConfig), ProductVariables: map[string]string{}, @@ -426,6 +427,10 @@ func seedSessionFromServer(ctx context.Context, rt *Runtime, projectID string, p if len(localizations) == 0 { localizations = json.RawMessage(`{"` + locale + `": {}}`) } + stateDeclarations := presentJSON(version.StateDeclarations) + if stateDeclarations == nil { + stateDeclarations = json.RawMessage(`{}`) + } var offeringID *string if paywall.OfferingID != "" { offeringID = &paywall.OfferingID @@ -446,6 +451,7 @@ func seedSessionFromServer(ctx context.Context, rt *Runtime, projectID string, p OfferingID: offeringID, ComponentsConfig: version.ComponentsConfig, ComponentsLocalizations: localizations, + StateDeclarations: stateDeclarations, }, UIConfig: json.RawMessage(minimalUIConfig), ProductVariables: map[string]string{}, @@ -589,10 +595,16 @@ func applySessionEvent(session *paywallAISession, event *paywallai.Event) { if event.Paywall != nil { // the editor echoes offering_id as null; keep the CLI's offeringID := session.Paywall.OfferingID + stateDeclarations := session.Paywall.StateDeclarations session.Paywall = *event.Paywall if session.Paywall.OfferingID == nil { session.Paywall.OfferingID = offeringID } + // an editor that predates state declarations doesn't echo them; absence means unknown, not cleared + session.Paywall.StateDeclarations = presentJSON(session.Paywall.StateDeclarations) + if session.Paywall.StateDeclarations == nil { + session.Paywall.StateDeclarations = stateDeclarations + } } if len(event.SessionItems) > 0 { session.SessionItems = event.SessionItems @@ -602,6 +614,15 @@ func applySessionEvent(session *paywallAISession, event *paywallai.Event) { } } +// presentJSON normalizes absent-or-null JSON to nil so it marshals as an +// omitted field: the server clears stored state on an explicit null. +func presentJSON(raw json.RawMessage) json.RawMessage { + if s := strings.TrimSpace(string(raw)); s == "" || s == "null" { + return nil + } + return raw +} + func paywallRecoveryHint(opts paywallAIOptions, checkpointed bool) string { if checkpointed { return "Progress so far is saved. Continue with: rc paywalls edit --session " + opts.sessionPath @@ -726,6 +747,7 @@ func persistPaywallDesign(ctx context.Context, rt *Runtime, session *paywallAISe update := api.PaywallDraftUpdate{ ComponentsConfig: session.Paywall.ComponentsConfig, ComponentsLocalizations: session.Paywall.ComponentsLocalizations, + StateDeclarations: presentJSON(session.Paywall.StateDeclarations), DefaultLocale: session.Paywall.DefaultLocale, } // Always the session's own revision — refetching a fresh one here would diff --git a/internal/cli/paywalls_ai_session_test.go b/internal/cli/paywalls_ai_session_test.go index a8d0f39b..7c30bca2 100644 --- a/internal/cli/paywalls_ai_session_test.go +++ b/internal/cli/paywalls_ai_session_test.go @@ -8,6 +8,7 @@ import ( "net/http" "net/http/httptest" "path/filepath" + "reflect" "sync" "testing" "time" @@ -111,8 +112,18 @@ func TestRunPaywallAI_EmptyRunStartedKeepsStoredSessionID(t *testing.T) { } type rcPaywallMock struct { - mu sync.Mutex - revision int + mu sync.Mutex + revision int + stateDeclarations string // raw JSON served on the draft; omitted when empty + patched []map[string]any +} + +func (m *rcPaywallMock) paywallJSON(rev int) string { + declarations := "" + if m.stateDeclarations != "" { + declarations = `,"state_declarations":` + m.stateDeclarations + } + return fmt.Sprintf(`{"id":"pw_test","offering_id":"","created_at":1,"components":{"published":null,"draft":{"revision":%d,"components_config":{"base":{}},"components_localizations":{"en_US":{}},"default_locale":"en_US"%s}}}`, rev, declarations) } func (m *rcPaywallMock) server(t *testing.T) *httptest.Server { @@ -124,27 +135,39 @@ func (m *rcPaywallMock) server(t *testing.T) *httptest.Server { m.mu.Lock() rev := m.revision m.mu.Unlock() - fmt.Fprintf(w, `{"id":"pw_test","offering_id":"","created_at":1,"components":{"published":null,"draft":{"revision":%d,"components_config":{"base":{}},"components_localizations":{"en_US":{}},"default_locale":"en_US"}}}`, rev) + fmt.Fprint(w, m.paywallJSON(rev)) case http.MethodPatch: - var body struct { - Revision int `json:"revision"` - } + var body map[string]any _ = json.NewDecoder(r.Body).Decode(&body) + revision, _ := body["revision"].(float64) m.mu.Lock() - m.revision = body.Revision + 1 + m.patched = append(m.patched, body) + m.revision = int(revision) + 1 rev := m.revision m.mu.Unlock() - fmt.Fprintf(w, `{"id":"pw_test","offering_id":"","created_at":1,"components":{"published":null,"draft":{"revision":%d,"components_config":{"base":{}},"components_localizations":{"en_US":{}},"default_locale":"en_US"}}}`, rev) + fmt.Fprint(w, m.paywallJSON(rev)) default: http.Error(w, "unexpected request", http.StatusMethodNotAllowed) } })) } +func (m *rcPaywallMock) lastPatched(t *testing.T) map[string]any { + t.Helper() + m.mu.Lock() + defer m.mu.Unlock() + if len(m.patched) == 0 { + t.Fatal("design was never PATCHed onto the draft") + } + return m.patched[len(m.patched)-1] +} + type echoEditorServer struct { - mu sync.Mutex - received []string - minted int + mu sync.Mutex + received []string + receivedDeclarations []string + declarations string // raw JSON echoed on the result paywall; omitted when empty + minted int } func (s *echoEditorServer) server(t *testing.T) *httptest.Server { @@ -152,10 +175,14 @@ func (s *echoEditorServer) server(t *testing.T) *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var body struct { SessionID string `json:"session_id"` + Paywall struct { + StateDeclarations json.RawMessage `json:"state_declarations"` + } `json:"paywall"` } _ = json.NewDecoder(r.Body).Decode(&body) s.mu.Lock() s.received = append(s.received, body.SessionID) + s.receivedDeclarations = append(s.receivedDeclarations, string(body.Paywall.StateDeclarations)) sid := body.SessionID if sid == "" { s.minted++ @@ -164,7 +191,11 @@ func (s *echoEditorServer) server(t *testing.T) *httptest.Server { s.mu.Unlock() w.Header().Set("Content-Type", "text/event-stream") - paywall := `{"default_locale":"en_US","offering_id":null,"components_config":{"designed":true},"components_localizations":{"en_US":{}}}` + declarations := "" + if s.declarations != "" { + declarations = `,"state_declarations":` + s.declarations + } + paywall := `{"default_locale":"en_US","offering_id":null,"components_config":{"designed":true},"components_localizations":{"en_US":{}}` + declarations + `}` fmt.Fprintf(w, "data: {\"type\":\"run.started\",\"session_id\":%q}\n\n", sid) fmt.Fprintf(w, "data: {\"type\":\"turn.snapshot\",\"session_id\":%q,\"turn_index\":0,\"paywall\":%s,\"activity\":[]}\n\n", sid, paywall) fmt.Fprintf(w, "data: {\"type\":\"run.completed\",\"session_id\":%q,\"trace_id\":\"tr1\",\"paywall\":%s,\"activity\":[]}\n\n", sid, paywall) @@ -251,3 +282,57 @@ func TestPaywallsEdit_ExplicitSessionOverridesDefault(t *testing.T) { t.Fatalf("--session did not drive the request session id: %v", received) } } + +func TestPaywallsEdit_RoundTripsStateDeclarations(t *testing.T) { + fetched := `{"tab":{"type":"string","default":"a"}}` + echoed := `{"tab":{"type":"string","default":"b"}}` + rc := &rcPaywallMock{stateDeclarations: fetched} + rcServer := rc.server(t) + defer rcServer.Close() + editor := &echoEditorServer{declarations: echoed} + editorServer := editor.server(t) + defer editorServer.Close() + + runEditTurn(t, t.TempDir(), editorServer.URL, rcServer.URL, "pw_test") + + editor.mu.Lock() + sent := append([]string(nil), editor.receivedDeclarations...) + editor.mu.Unlock() + if len(sent) != 1 || sent[0] != fetched { + t.Fatalf("editor request state_declarations = %v, want %s", sent, fetched) + } + + var want any + if err := json.Unmarshal([]byte(echoed), &want); err != nil { + t.Fatal(err) + } + if got := rc.lastPatched(t)["state_declarations"]; !reflect.DeepEqual(got, want) { + t.Fatalf("PATCH state_declarations = %v, want %v", got, want) + } +} + +// A draft not written since before state declarations existed serves them as +// null; the CLI must send the editor an empty-but-present value (so it can +// wire new state) and must never PATCH an explicit null back (which clears). +func TestPaywallsEdit_NeverPatchesNullStateDeclarations(t *testing.T) { + rc := &rcPaywallMock{stateDeclarations: "null"} + rcServer := rc.server(t) + defer rcServer.Close() + editor := &echoEditorServer{} // an older editor that never echoes declarations + editorServer := editor.server(t) + defer editorServer.Close() + + runEditTurn(t, t.TempDir(), editorServer.URL, rcServer.URL, "pw_test") + + editor.mu.Lock() + sent := append([]string(nil), editor.receivedDeclarations...) + editor.mu.Unlock() + if len(sent) != 1 || sent[0] != "{}" { + t.Fatalf("editor request state_declarations = %v, want {}", sent) + } + + got, ok := rc.lastPatched(t)["state_declarations"] + if !ok || !reflect.DeepEqual(got, map[string]any{}) { + t.Fatalf("PATCH state_declarations = %v (present: %v), want {}", got, ok) + } +} diff --git a/internal/cli/paywalls_stream_test.go b/internal/cli/paywalls_stream_test.go index e0bf4024..f2ed7378 100644 --- a/internal/cli/paywalls_stream_test.go +++ b/internal/cli/paywalls_stream_test.go @@ -32,6 +32,29 @@ func TestApplySessionEvent_PreservesOffering(t *testing.T) { } } +func TestApplySessionEvent_PreservesStateDeclarations(t *testing.T) { + session := &paywallAISession{} + session.Paywall.StateDeclarations = json.RawMessage(`{"tab":{"type":"string","default":"a"}}`) + + // an editor that predates state declarations omits them; that must not drop the session's + applySessionEvent(session, &paywallai.Event{ + Paywall: &paywallai.PaywallData{DefaultLocale: "en_US"}, + }) + if string(session.Paywall.StateDeclarations) != `{"tab":{"type":"string","default":"a"}}` { + t.Fatalf("declarations not preserved: %s", session.Paywall.StateDeclarations) + } + + applySessionEvent(session, &paywallai.Event{ + Paywall: &paywallai.PaywallData{ + DefaultLocale: "en_US", + StateDeclarations: json.RawMessage(`{"tab":{"type":"string","default":"b"}}`), + }, + }) + if string(session.Paywall.StateDeclarations) != `{"tab":{"type":"string","default":"b"}}` { + t.Fatalf("echoed declarations not applied: %s", session.Paywall.StateDeclarations) + } +} + func TestStreamDropError(t *testing.T) { underlying := errors.New("stream ID 1; INTERNAL_ERROR; received from peer") diff --git a/internal/paywallai/paywallai.go b/internal/paywallai/paywallai.go index 78fb35b9..214ffdf3 100644 --- a/internal/paywallai/paywallai.go +++ b/internal/paywallai/paywallai.go @@ -34,6 +34,9 @@ type PaywallData struct { OfferingID *string `json:"offering_id"` ComponentsConfig json.RawMessage `json:"components_config"` ComponentsLocalizations json.RawMessage `json:"components_localizations"` + // Sending StateDeclarations (even empty) tells the editor the client can + // round-trip declarations, which lets it wire up new paywall state. + StateDeclarations json.RawMessage `json:"state_declarations,omitempty"` } type InputAttachment struct { From f40f822dd12e71d7853baee25ce6d11961d556e7 Mon Sep 17 00:00:00 2001 From: Azat Valiev Date: Thu, 27 Aug 2026 13:13:00 +0000 Subject: [PATCH 2/3] hydrate state_declarations onto resumed sessions from the server draft --- internal/cli/paywalls_ai.go | 53 +++++++++++++++++------- internal/cli/paywalls_ai_session_test.go | 38 +++++++++++++++++ 2 files changed, 77 insertions(+), 14 deletions(-) diff --git a/internal/cli/paywalls_ai.go b/internal/cli/paywalls_ai.go index 2c9ec99b..502598f0 100644 --- a/internal/cli/paywalls_ai.go +++ b/internal/cli/paywalls_ai.go @@ -361,14 +361,15 @@ func preflightSessionRevision(ctx context.Context, rt *Runtime, session *paywall if err != nil { return nil, err } - revision, err := currentDraftRevision(ctx, client, session.ProjectID, session.PaywallID) + version, err := currentDraftVersion(ctx, client, session.ProjectID, session.PaywallID) if err != nil { return nil, err } - if revision == *session.Revision { + if *version.Revision == *session.Revision { + hydrateStateDeclarations(session, version) return session, nil } - rt.Out.Warn(fmt.Sprintf("The draft for %s changed outside this session — the dashboard, its AI editor, or the API wrote revision %d, the session has %d.", session.PaywallID, revision, *session.Revision)) + rt.Out.Warn(fmt.Sprintf("The draft for %s changed outside this session — the dashboard, its AI editor, or the API wrote revision %d, the session has %d.", session.PaywallID, *version.Revision, *session.Revision)) rt.Out.Info("A session can't continue against diverged state. Continuing starts fresh from the server's current draft; the conversation context in this session file is lost.") if err := confirmOrAbort(rt, "Start fresh from the server's current draft?", "run rc paywalls edit "+session.PaywallID+" to start fresh deliberately"); err != nil { @@ -388,11 +389,12 @@ func resumeOrSeedSession(ctx context.Context, rt *Runtime, projectID, paywallID, if err != nil { return nil, err } - revision, err := currentDraftRevision(ctx, client, projectID, paywallID) + version, err := currentDraftVersion(ctx, client, projectID, paywallID) if err != nil { return nil, err } - if stored.Revision != nil && revision == *stored.Revision { + if stored.Revision != nil && *version.Revision == *stored.Revision { + hydrateStateDeclarations(stored, version) return stored, nil } return seedSessionFromServer(ctx, rt, projectID, paywallID) @@ -427,10 +429,6 @@ func seedSessionFromServer(ctx context.Context, rt *Runtime, projectID string, p if len(localizations) == 0 { localizations = json.RawMessage(`{"` + locale + `": {}}`) } - stateDeclarations := presentJSON(version.StateDeclarations) - if stateDeclarations == nil { - stateDeclarations = json.RawMessage(`{}`) - } var offeringID *string if paywall.OfferingID != "" { offeringID = &paywall.OfferingID @@ -451,7 +449,7 @@ func seedSessionFromServer(ctx context.Context, rt *Runtime, projectID string, p OfferingID: offeringID, ComponentsConfig: version.ComponentsConfig, ComponentsLocalizations: localizations, - StateDeclarations: stateDeclarations, + StateDeclarations: serverStateDeclarations(version), }, UIConfig: json.RawMessage(minimalUIConfig), ProductVariables: map[string]string{}, @@ -614,6 +612,23 @@ func applySessionEvent(session *paywallAISession, event *paywallai.Event) { } } +// hydrateStateDeclarations backfills declarations onto a session written by a +// CLI from before they existed, so the editor can round-trip them again. The +// server's value, not {}: the stored draft may hold dashboard-authored +// declarations that an empty replacement would wipe. +func hydrateStateDeclarations(session *paywallAISession, version *api.PaywallComponentsVersion) { + if presentJSON(session.Paywall.StateDeclarations) == nil { + session.Paywall.StateDeclarations = serverStateDeclarations(version) + } +} + +func serverStateDeclarations(version *api.PaywallComponentsVersion) json.RawMessage { + if declarations := presentJSON(version.StateDeclarations); declarations != nil { + return declarations + } + return json.RawMessage(`{}`) +} + // presentJSON normalizes absent-or-null JSON to nil so it marshals as an // omitted field: the server clears stored state on an explicit null. func presentJSON(raw json.RawMessage) json.RawMessage { @@ -772,20 +787,30 @@ func persistPaywallDesign(ctx context.Context, rt *Runtime, session *paywallAISe } func currentDraftRevision(ctx context.Context, client *api.Client, projectID, paywallID string) (int, error) { - paywall, err := client.Paywalls.GetWithComponents(ctx, projectID, paywallID) + version, err := currentDraftVersion(ctx, client, projectID, paywallID) if err != nil { return 0, err } + return *version.Revision, nil +} + +// currentDraftVersion returns the paywall's draft version (falling back to +// published), guaranteed to carry a revision. +func currentDraftVersion(ctx context.Context, client *api.Client, projectID, paywallID string) (*api.PaywallComponentsVersion, error) { + paywall, err := client.Paywalls.GetWithComponents(ctx, projectID, paywallID) + if err != nil { + return nil, err + } if paywall.Components != nil { if d := paywall.Components.Draft; d != nil && d.Revision != nil { - return *d.Revision, nil + return d, nil } if p := paywall.Components.Published; p != nil && p.Revision != nil { - return *p.Revision, nil + return p, nil } } // revision is the update PATCH's stale-write token, so error rather than send a bogus 0. - return 0, fmt.Errorf("paywall %s has no draft or published revision to update against", paywallID) + return nil, fmt.Errorf("paywall %s has no draft or published revision to update against", paywallID) } // reportPaywallAIActivity prints activity items not yet shown; snapshots carry diff --git a/internal/cli/paywalls_ai_session_test.go b/internal/cli/paywalls_ai_session_test.go index 7c30bca2..6727765d 100644 --- a/internal/cli/paywalls_ai_session_test.go +++ b/internal/cli/paywalls_ai_session_test.go @@ -311,6 +311,44 @@ func TestPaywallsEdit_RoundTripsStateDeclarations(t *testing.T) { } } +// A session file written by a CLI from before state declarations must adopt +// the server's declarations on resume — not {}, which would wipe +// dashboard-authored declarations on save. +func TestPaywallsEdit_LegacySessionAdoptsServerStateDeclarations(t *testing.T) { + fetched := `{"tab":{"type":"string","default":"a"}}` + rc := &rcPaywallMock{stateDeclarations: fetched} + rcServer := rc.server(t) + defer rcServer.Close() + editor := &echoEditorServer{} + editorServer := editor.server(t) + defer editorServer.Close() + + dir := t.TempDir() + sessionPath := filepath.Join(dir, "legacy"+paywallSessionSuffix) + if err := savePaywallAISession(sessionPath, newTestSession()); err != nil { + t.Fatal(err) + } + + t.Setenv("RC_CONFIG_DIR", t.TempDir()) + t.Setenv("RC_PAYWALL_AI_BASE_URL", editorServer.URL) + cmd := newPaywallsEditCmd() + rt := newSessionTestRuntime(rcServer.URL) + cmd.SetContext(WithRuntime(context.Background(), rt)) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs([]string{"--session", sessionPath, "--prompt", "tweak it"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("edit with --session failed: %v", err) + } + + editor.mu.Lock() + sent := append([]string(nil), editor.receivedDeclarations...) + editor.mu.Unlock() + if len(sent) != 1 || sent[0] != fetched { + t.Fatalf("editor request state_declarations = %v, want %s", sent, fetched) + } +} + // A draft not written since before state declarations existed serves them as // null; the CLI must send the editor an empty-but-present value (so it can // wire new state) and must never PATCH an explicit null back (which clears). From c640ac45ebb6ef6fceb5e99ac34253a21a8723df Mon Sep 17 00:00:00 2001 From: Azat Valiev Date: Sat, 29 Aug 2026 17:02:45 +0200 Subject: [PATCH 3/3] docs(specs): mirror khepri's state_declarations on PaywallComponentsVersion The checked-in v2 snapshot predates khepri's schema change, so the components-version response schema didn't admit the state_declarations field the live endpoint already returns. Mirror the khepri definition (required nullable map of discriminated StateDeclaration variants) until the published spec catches up and spec-sync converges. Copied from khepri @ cf3f5ea523aee8d5cffb27dd37437380e9a73889. --- docs/specs/v2-developer.yaml | 103 ++++++ internal/api/types_gen.go | 631 +++++++++++++++++++++++++++++++++++ 2 files changed, 734 insertions(+) diff --git a/docs/specs/v2-developer.yaml b/docs/specs/v2-developer.yaml index 67a821e6..a7b59470 100644 --- a/docs/specs/v2-developer.yaml +++ b/docs/specs/v2-developer.yaml @@ -13762,6 +13762,7 @@ components: - default_locale - components_localizations - automatically_scale_font_size + - state_declarations properties: revision: description: The revision number of this version. @@ -13806,6 +13807,20 @@ components: type: boolean default: true example: true + state_declarations: + description: >- + State declarations for this version, keyed by state key name. An + empty object when the paywall has no state declarations; null for + paywalls not written to since before state declarations were + introduced. + type: object + nullable: true + additionalProperties: + $ref: '#/components/schemas/StateDeclaration' + example: + selectedPackage: + type: string + default: monthly fonts: description: >- Font objects used by the published paywall components. Only present @@ -15014,6 +15029,94 @@ components: maxLength: 30 additionalProperties: false additionalProperties: false + StateDeclaration: + description: >- + Declaration of a single paywall state key, giving its value type and a + default value matching that type. + oneOf: + - $ref: '#/components/schemas/StateDeclarationBooleanVariant' + - $ref: '#/components/schemas/StateDeclarationIntegerVariant' + - $ref: '#/components/schemas/StateDeclarationDoubleVariant' + - $ref: '#/components/schemas/StateDeclarationStringVariant' + discriminator: + propertyName: type + mapping: + boolean: '#/components/schemas/StateDeclarationBooleanVariant' + integer: '#/components/schemas/StateDeclarationIntegerVariant' + double: '#/components/schemas/StateDeclarationDoubleVariant' + string: '#/components/schemas/StateDeclarationStringVariant' + StateDeclarationBooleanVariant: + description: Declaration of a boolean-valued paywall state key. + type: object + required: + - type + - default + properties: + type: + description: The value type of the state key. + type: string + enum: + - boolean + example: boolean + default: + description: The initial value for the state key. + type: boolean + example: true + additionalProperties: true + StateDeclarationDoubleVariant: + description: Declaration of a double-valued paywall state key. + type: object + required: + - type + - default + properties: + type: + description: The value type of the state key. + type: string + enum: + - double + example: double + default: + description: The initial value for the state key. + type: number + example: 0.25 + additionalProperties: true + StateDeclarationIntegerVariant: + description: Declaration of a integer-valued paywall state key. + type: object + required: + - type + - default + properties: + type: + description: The value type of the state key. + type: string + enum: + - integer + example: integer + default: + description: The initial value for the state key. + type: integer + example: 3 + additionalProperties: true + StateDeclarationStringVariant: + description: Declaration of a string-valued paywall state key. + type: object + required: + - type + - default + properties: + type: + description: The value type of the state key. + type: string + enum: + - string + example: string + default: + description: The initial value for the state key. + type: string + example: monthly + additionalProperties: true StoreKitConfigFile: type: object description: Contents of a generated StoreKit config file for an app diff --git a/internal/api/types_gen.go b/internal/api/types_gen.go index 3c81a173..0ec9f15e 100644 --- a/internal/api/types_gen.go +++ b/internal/api/types_gen.go @@ -5,6 +5,7 @@ package api import ( "encoding/json" + "errors" "fmt" "time" @@ -3393,6 +3394,66 @@ func (e RokuAppCreateType) Valid() bool { } } +// Defines values for StateDeclarationBooleanVariantType. +const ( + Boolean StateDeclarationBooleanVariantType = "boolean" +) + +// Valid indicates whether the value is a known member of the StateDeclarationBooleanVariantType enum. +func (e StateDeclarationBooleanVariantType) Valid() bool { + switch e { + case Boolean: + return true + default: + return false + } +} + +// Defines values for StateDeclarationDoubleVariantType. +const ( + Double StateDeclarationDoubleVariantType = "double" +) + +// Valid indicates whether the value is a known member of the StateDeclarationDoubleVariantType enum. +func (e StateDeclarationDoubleVariantType) Valid() bool { + switch e { + case Double: + return true + default: + return false + } +} + +// Defines values for StateDeclarationIntegerVariantType. +const ( + Integer StateDeclarationIntegerVariantType = "integer" +) + +// Valid indicates whether the value is a known member of the StateDeclarationIntegerVariantType enum. +func (e StateDeclarationIntegerVariantType) Valid() bool { + switch e { + case Integer: + return true + default: + return false + } +} + +// Defines values for StateDeclarationStringVariantType. +const ( + String StateDeclarationStringVariantType = "string" +) + +// Valid indicates whether the value is a known member of the StateDeclarationStringVariantType enum. +func (e StateDeclarationStringVariantType) Valid() bool { + switch e { + case String: + return true + default: + return false + } +} + // Defines values for StoreKitConfigFileObject. const ( StoreKitConfigFileObjectStoreKitConfigFile StoreKitConfigFileObject = "store_kit_config_file" @@ -37462,6 +37523,87 @@ type RokuAppCreate struct { // Mac App Store is disabled by default. See [Legacy Mac Apps](https://www.revenuecat.com/docs/legacy-mac-apps) for more details. type RokuAppCreateType string +// StateDeclaration Declaration of a single paywall state key, giving its value type and a default value matching that type. +type StateDeclaration struct { + union json.RawMessage +} + +// StateDeclarationBooleanVariant Declaration of a boolean-valued paywall state key. +type StateDeclarationBooleanVariant struct { + // Default The initial value for the state key. + // + // Example: true + Default bool `json:"default"` + + // Type The value type of the state key. + // + // Example: boolean + Type StateDeclarationBooleanVariantType `json:"type"` + AdditionalProperties map[string]interface{} `json:"-"` +} + +// StateDeclarationBooleanVariantType The value type of the state key. +// +// Example: boolean +type StateDeclarationBooleanVariantType string + +// StateDeclarationDoubleVariant Declaration of a double-valued paywall state key. +type StateDeclarationDoubleVariant struct { + // Default The initial value for the state key. + // + // Example: 0.25 + Default float32 `json:"default"` + + // Type The value type of the state key. + // + // Example: double + Type StateDeclarationDoubleVariantType `json:"type"` + AdditionalProperties map[string]interface{} `json:"-"` +} + +// StateDeclarationDoubleVariantType The value type of the state key. +// +// Example: double +type StateDeclarationDoubleVariantType string + +// StateDeclarationIntegerVariant Declaration of a integer-valued paywall state key. +type StateDeclarationIntegerVariant struct { + // Default The initial value for the state key. + // + // Example: 3 + Default int `json:"default"` + + // Type The value type of the state key. + // + // Example: integer + Type StateDeclarationIntegerVariantType `json:"type"` + AdditionalProperties map[string]interface{} `json:"-"` +} + +// StateDeclarationIntegerVariantType The value type of the state key. +// +// Example: integer +type StateDeclarationIntegerVariantType string + +// StateDeclarationStringVariant Declaration of a string-valued paywall state key. +type StateDeclarationStringVariant struct { + // Default The initial value for the state key. + // + // Example: monthly + Default string `json:"default"` + + // Type The value type of the state key. + // + // Example: string + Type StateDeclarationStringVariantType `json:"type"` + AdditionalProperties map[string]interface{} `json:"-"` +} + +// StateDeclarationStringVariantType The value type of the state key. +// +// Example: string +type StateDeclarationStringVariantType string + // StoreKitConfigFile Contents of a generated StoreKit config file for an app type StoreKitConfigFile struct { // Contents Contents of the StoreKit config file @@ -48094,6 +48236,322 @@ func (a RokuApp_Roku) MarshalJSON() ([]byte, error) { return json.Marshal(object) } +// Getter for additional properties for StateDeclarationBooleanVariant. Returns the specified +// element and whether it was found +func (a StateDeclarationBooleanVariant) Get(fieldName string) (value interface{}, found bool) { + if a.AdditionalProperties != nil { + value, found = a.AdditionalProperties[fieldName] + } + return +} + +// Setter for additional properties for StateDeclarationBooleanVariant +func (a *StateDeclarationBooleanVariant) Set(fieldName string, value interface{}) { + if a.AdditionalProperties == nil { + a.AdditionalProperties = make(map[string]interface{}) + } + a.AdditionalProperties[fieldName] = value +} + +// Override default JSON handling for StateDeclarationBooleanVariant to handle AdditionalProperties +func (a *StateDeclarationBooleanVariant) UnmarshalJSON(b []byte) error { + object := make(map[string]json.RawMessage) + err := json.Unmarshal(b, &object) + if err != nil { + return err + } + + if raw, found := object["default"]; found { + err = json.Unmarshal(raw, &a.Default) + if err != nil { + return fmt.Errorf("error reading 'default': %w", err) + } + delete(object, "default") + } + + if raw, found := object["type"]; found { + err = json.Unmarshal(raw, &a.Type) + if err != nil { + return fmt.Errorf("error reading 'type': %w", err) + } + delete(object, "type") + } + + if len(object) != 0 { + a.AdditionalProperties = make(map[string]interface{}) + for fieldName, fieldBuf := range object { + var fieldVal interface{} + err := json.Unmarshal(fieldBuf, &fieldVal) + if err != nil { + return fmt.Errorf("error unmarshaling field %s: %w", fieldName, err) + } + a.AdditionalProperties[fieldName] = fieldVal + } + } + return nil +} + +// Override default JSON handling for StateDeclarationBooleanVariant to handle AdditionalProperties +func (a StateDeclarationBooleanVariant) MarshalJSON() ([]byte, error) { + var err error + object := make(map[string]json.RawMessage) + + object["default"], err = json.Marshal(a.Default) + if err != nil { + return nil, fmt.Errorf("error marshaling 'default': %w", err) + } + + object["type"], err = json.Marshal(a.Type) + if err != nil { + return nil, fmt.Errorf("error marshaling 'type': %w", err) + } + + for fieldName, field := range a.AdditionalProperties { + object[fieldName], err = json.Marshal(field) + if err != nil { + return nil, fmt.Errorf("error marshaling '%s': %w", fieldName, err) + } + } + return json.Marshal(object) +} + +// Getter for additional properties for StateDeclarationDoubleVariant. Returns the specified +// element and whether it was found +func (a StateDeclarationDoubleVariant) Get(fieldName string) (value interface{}, found bool) { + if a.AdditionalProperties != nil { + value, found = a.AdditionalProperties[fieldName] + } + return +} + +// Setter for additional properties for StateDeclarationDoubleVariant +func (a *StateDeclarationDoubleVariant) Set(fieldName string, value interface{}) { + if a.AdditionalProperties == nil { + a.AdditionalProperties = make(map[string]interface{}) + } + a.AdditionalProperties[fieldName] = value +} + +// Override default JSON handling for StateDeclarationDoubleVariant to handle AdditionalProperties +func (a *StateDeclarationDoubleVariant) UnmarshalJSON(b []byte) error { + object := make(map[string]json.RawMessage) + err := json.Unmarshal(b, &object) + if err != nil { + return err + } + + if raw, found := object["default"]; found { + err = json.Unmarshal(raw, &a.Default) + if err != nil { + return fmt.Errorf("error reading 'default': %w", err) + } + delete(object, "default") + } + + if raw, found := object["type"]; found { + err = json.Unmarshal(raw, &a.Type) + if err != nil { + return fmt.Errorf("error reading 'type': %w", err) + } + delete(object, "type") + } + + if len(object) != 0 { + a.AdditionalProperties = make(map[string]interface{}) + for fieldName, fieldBuf := range object { + var fieldVal interface{} + err := json.Unmarshal(fieldBuf, &fieldVal) + if err != nil { + return fmt.Errorf("error unmarshaling field %s: %w", fieldName, err) + } + a.AdditionalProperties[fieldName] = fieldVal + } + } + return nil +} + +// Override default JSON handling for StateDeclarationDoubleVariant to handle AdditionalProperties +func (a StateDeclarationDoubleVariant) MarshalJSON() ([]byte, error) { + var err error + object := make(map[string]json.RawMessage) + + object["default"], err = json.Marshal(a.Default) + if err != nil { + return nil, fmt.Errorf("error marshaling 'default': %w", err) + } + + object["type"], err = json.Marshal(a.Type) + if err != nil { + return nil, fmt.Errorf("error marshaling 'type': %w", err) + } + + for fieldName, field := range a.AdditionalProperties { + object[fieldName], err = json.Marshal(field) + if err != nil { + return nil, fmt.Errorf("error marshaling '%s': %w", fieldName, err) + } + } + return json.Marshal(object) +} + +// Getter for additional properties for StateDeclarationIntegerVariant. Returns the specified +// element and whether it was found +func (a StateDeclarationIntegerVariant) Get(fieldName string) (value interface{}, found bool) { + if a.AdditionalProperties != nil { + value, found = a.AdditionalProperties[fieldName] + } + return +} + +// Setter for additional properties for StateDeclarationIntegerVariant +func (a *StateDeclarationIntegerVariant) Set(fieldName string, value interface{}) { + if a.AdditionalProperties == nil { + a.AdditionalProperties = make(map[string]interface{}) + } + a.AdditionalProperties[fieldName] = value +} + +// Override default JSON handling for StateDeclarationIntegerVariant to handle AdditionalProperties +func (a *StateDeclarationIntegerVariant) UnmarshalJSON(b []byte) error { + object := make(map[string]json.RawMessage) + err := json.Unmarshal(b, &object) + if err != nil { + return err + } + + if raw, found := object["default"]; found { + err = json.Unmarshal(raw, &a.Default) + if err != nil { + return fmt.Errorf("error reading 'default': %w", err) + } + delete(object, "default") + } + + if raw, found := object["type"]; found { + err = json.Unmarshal(raw, &a.Type) + if err != nil { + return fmt.Errorf("error reading 'type': %w", err) + } + delete(object, "type") + } + + if len(object) != 0 { + a.AdditionalProperties = make(map[string]interface{}) + for fieldName, fieldBuf := range object { + var fieldVal interface{} + err := json.Unmarshal(fieldBuf, &fieldVal) + if err != nil { + return fmt.Errorf("error unmarshaling field %s: %w", fieldName, err) + } + a.AdditionalProperties[fieldName] = fieldVal + } + } + return nil +} + +// Override default JSON handling for StateDeclarationIntegerVariant to handle AdditionalProperties +func (a StateDeclarationIntegerVariant) MarshalJSON() ([]byte, error) { + var err error + object := make(map[string]json.RawMessage) + + object["default"], err = json.Marshal(a.Default) + if err != nil { + return nil, fmt.Errorf("error marshaling 'default': %w", err) + } + + object["type"], err = json.Marshal(a.Type) + if err != nil { + return nil, fmt.Errorf("error marshaling 'type': %w", err) + } + + for fieldName, field := range a.AdditionalProperties { + object[fieldName], err = json.Marshal(field) + if err != nil { + return nil, fmt.Errorf("error marshaling '%s': %w", fieldName, err) + } + } + return json.Marshal(object) +} + +// Getter for additional properties for StateDeclarationStringVariant. Returns the specified +// element and whether it was found +func (a StateDeclarationStringVariant) Get(fieldName string) (value interface{}, found bool) { + if a.AdditionalProperties != nil { + value, found = a.AdditionalProperties[fieldName] + } + return +} + +// Setter for additional properties for StateDeclarationStringVariant +func (a *StateDeclarationStringVariant) Set(fieldName string, value interface{}) { + if a.AdditionalProperties == nil { + a.AdditionalProperties = make(map[string]interface{}) + } + a.AdditionalProperties[fieldName] = value +} + +// Override default JSON handling for StateDeclarationStringVariant to handle AdditionalProperties +func (a *StateDeclarationStringVariant) UnmarshalJSON(b []byte) error { + object := make(map[string]json.RawMessage) + err := json.Unmarshal(b, &object) + if err != nil { + return err + } + + if raw, found := object["default"]; found { + err = json.Unmarshal(raw, &a.Default) + if err != nil { + return fmt.Errorf("error reading 'default': %w", err) + } + delete(object, "default") + } + + if raw, found := object["type"]; found { + err = json.Unmarshal(raw, &a.Type) + if err != nil { + return fmt.Errorf("error reading 'type': %w", err) + } + delete(object, "type") + } + + if len(object) != 0 { + a.AdditionalProperties = make(map[string]interface{}) + for fieldName, fieldBuf := range object { + var fieldVal interface{} + err := json.Unmarshal(fieldBuf, &fieldVal) + if err != nil { + return fmt.Errorf("error unmarshaling field %s: %w", fieldName, err) + } + a.AdditionalProperties[fieldName] = fieldVal + } + } + return nil +} + +// Override default JSON handling for StateDeclarationStringVariant to handle AdditionalProperties +func (a StateDeclarationStringVariant) MarshalJSON() ([]byte, error) { + var err error + object := make(map[string]json.RawMessage) + + object["default"], err = json.Marshal(a.Default) + if err != nil { + return nil, fmt.Errorf("error marshaling 'default': %w", err) + } + + object["type"], err = json.Marshal(a.Type) + if err != nil { + return nil, fmt.Errorf("error marshaling 'type': %w", err) + } + + for fieldName, field := range a.AdditionalProperties { + object[fieldName], err = json.Marshal(field) + if err != nil { + return nil, fmt.Errorf("error marshaling '%s': %w", fieldName, err) + } + } + return json.Marshal(object) +} + // Getter for additional properties for StripeApp. Returns the specified // element and whether it was found func (a StripeApp) Get(fieldName string) (value interface{}, found bool) { @@ -48879,6 +49337,179 @@ func (t *Purchase_RevenueInUsd) UnmarshalJSON(b []byte) error { return err } +// AsStateDeclarationBooleanVariant returns the union data inside the StateDeclaration as a StateDeclarationBooleanVariant +func (t StateDeclaration) AsStateDeclarationBooleanVariant() (StateDeclarationBooleanVariant, error) { + var body StateDeclarationBooleanVariant + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromStateDeclarationBooleanVariant overwrites any union data inside the StateDeclaration as the provided StateDeclarationBooleanVariant +func (t *StateDeclaration) FromStateDeclarationBooleanVariant(v StateDeclarationBooleanVariant) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + b, err = runtime.JSONMerge(b, []byte(`{"type":"boolean"}`)) + t.union = b + return err +} + +// MergeStateDeclarationBooleanVariant performs a merge with any union data inside the StateDeclaration, using the provided StateDeclarationBooleanVariant +func (t *StateDeclaration) MergeStateDeclarationBooleanVariant(v StateDeclarationBooleanVariant) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + b, err = runtime.JSONMerge(b, []byte(`{"type":"boolean"}`)) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsStateDeclarationIntegerVariant returns the union data inside the StateDeclaration as a StateDeclarationIntegerVariant +func (t StateDeclaration) AsStateDeclarationIntegerVariant() (StateDeclarationIntegerVariant, error) { + var body StateDeclarationIntegerVariant + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromStateDeclarationIntegerVariant overwrites any union data inside the StateDeclaration as the provided StateDeclarationIntegerVariant +func (t *StateDeclaration) FromStateDeclarationIntegerVariant(v StateDeclarationIntegerVariant) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + b, err = runtime.JSONMerge(b, []byte(`{"type":"integer"}`)) + t.union = b + return err +} + +// MergeStateDeclarationIntegerVariant performs a merge with any union data inside the StateDeclaration, using the provided StateDeclarationIntegerVariant +func (t *StateDeclaration) MergeStateDeclarationIntegerVariant(v StateDeclarationIntegerVariant) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + b, err = runtime.JSONMerge(b, []byte(`{"type":"integer"}`)) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsStateDeclarationDoubleVariant returns the union data inside the StateDeclaration as a StateDeclarationDoubleVariant +func (t StateDeclaration) AsStateDeclarationDoubleVariant() (StateDeclarationDoubleVariant, error) { + var body StateDeclarationDoubleVariant + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromStateDeclarationDoubleVariant overwrites any union data inside the StateDeclaration as the provided StateDeclarationDoubleVariant +func (t *StateDeclaration) FromStateDeclarationDoubleVariant(v StateDeclarationDoubleVariant) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + b, err = runtime.JSONMerge(b, []byte(`{"type":"double"}`)) + t.union = b + return err +} + +// MergeStateDeclarationDoubleVariant performs a merge with any union data inside the StateDeclaration, using the provided StateDeclarationDoubleVariant +func (t *StateDeclaration) MergeStateDeclarationDoubleVariant(v StateDeclarationDoubleVariant) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + b, err = runtime.JSONMerge(b, []byte(`{"type":"double"}`)) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsStateDeclarationStringVariant returns the union data inside the StateDeclaration as a StateDeclarationStringVariant +func (t StateDeclaration) AsStateDeclarationStringVariant() (StateDeclarationStringVariant, error) { + var body StateDeclarationStringVariant + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromStateDeclarationStringVariant overwrites any union data inside the StateDeclaration as the provided StateDeclarationStringVariant +func (t *StateDeclaration) FromStateDeclarationStringVariant(v StateDeclarationStringVariant) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + b, err = runtime.JSONMerge(b, []byte(`{"type":"string"}`)) + t.union = b + return err +} + +// MergeStateDeclarationStringVariant performs a merge with any union data inside the StateDeclaration, using the provided StateDeclarationStringVariant +func (t *StateDeclaration) MergeStateDeclarationStringVariant(v StateDeclarationStringVariant) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + b, err = runtime.JSONMerge(b, []byte(`{"type":"string"}`)) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t StateDeclaration) Discriminator() (string, error) { + var discriminator struct { + Discriminator string `json:"type"` + } + err := json.Unmarshal(t.union, &discriminator) + return discriminator.Discriminator, err +} + +func (t StateDeclaration) ValueByDiscriminator() (interface{}, error) { + discriminator, err := t.Discriminator() + if err != nil { + return nil, err + } + switch discriminator { + case "boolean": + return t.AsStateDeclarationBooleanVariant() + case "double": + return t.AsStateDeclarationDoubleVariant() + case "integer": + return t.AsStateDeclarationIntegerVariant() + case "string": + return t.AsStateDeclarationStringVariant() + default: + return nil, errors.New("unknown discriminator value: " + discriminator) + } +} + +func (t StateDeclaration) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *StateDeclaration) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + // AsEnvironment returns the union data inside the Subscription_Environment as a Environment func (t Subscription_Environment) AsEnvironment() (Environment, error) { var body Environment