ecoportal-api-graphql 2.2.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +405 -9
  3. data/README.gem.md +53 -0
  4. data/lib/ecoportal/api/common/graphql/auth_service.rb +1 -1
  5. data/lib/ecoportal/api/common/graphql/client.rb +38 -0
  6. data/lib/ecoportal/api/common/graphql/http_client.rb +39 -6
  7. data/lib/ecoportal/api/graphql/base/page/data_field/image_gallery.rb +159 -11
  8. data/lib/ecoportal/api/graphql/base/temp_image.rb +28 -0
  9. data/lib/ecoportal/api/graphql/base.rb +1 -0
  10. data/lib/ecoportal/api/graphql/builder/template.rb +9 -4
  11. data/lib/ecoportal/api/graphql/compat/filter_translator.rb +1 -1
  12. data/lib/ecoportal/api/graphql/file_upload/client.rb +140 -35
  13. data/lib/ecoportal/api/graphql/fragment/pages/common_page_union.rb +6 -1
  14. data/lib/ecoportal/api/graphql/input/page/update.rb +110 -7
  15. data/lib/ecoportal/api/graphql/input/search_conf.rb +1 -1
  16. data/lib/ecoportal/api/graphql/model/temp_image.rb +10 -0
  17. data/lib/ecoportal/api/graphql/model/template/binding.rb +60 -0
  18. data/lib/ecoportal/api/graphql/model/template/command_grouper.rb +107 -0
  19. data/lib/ecoportal/api/graphql/model/template/command_normalizer.rb +116 -0
  20. data/lib/ecoportal/api/graphql/model/template/command_synthesis.rb +262 -0
  21. data/lib/ecoportal/api/graphql/model/template/field.rb +68 -0
  22. data/lib/ecoportal/api/graphql/model/template/force.rb +65 -0
  23. data/lib/ecoportal/api/graphql/model/template/helper.rb +32 -0
  24. data/lib/ecoportal/api/graphql/model/template/instance.rb +202 -0
  25. data/lib/ecoportal/api/graphql/model/template/node.rb +78 -0
  26. data/lib/ecoportal/api/graphql/model/template/option.rb +49 -0
  27. data/lib/ecoportal/api/graphql/model/template/read.rb +164 -0
  28. data/lib/ecoportal/api/graphql/model/template/section.rb +82 -0
  29. data/lib/ecoportal/api/graphql/model/template/stage.rb +49 -0
  30. data/lib/ecoportal/api/graphql/model/template/staged_executor.rb +236 -0
  31. data/lib/ecoportal/api/graphql/model/template.rb +33 -0
  32. data/lib/ecoportal/api/graphql/model.rb +1 -0
  33. data/lib/ecoportal/api/graphql/mutation/file_container/upload.rb +11 -4
  34. data/lib/ecoportal/api/graphql/mutation/image/upload.rb +88 -0
  35. data/lib/ecoportal/api/graphql/mutation/image.rb +14 -0
  36. data/lib/ecoportal/api/graphql/mutation/template/create.rb +35 -3
  37. data/lib/ecoportal/api/graphql/mutation/template/update.rb +4 -2
  38. data/lib/ecoportal/api/graphql/mutation.rb +1 -0
  39. data/lib/ecoportal/api/graphql/payload/images_upload.rb +14 -0
  40. data/lib/ecoportal/api/graphql/payload.rb +1 -0
  41. data/lib/ecoportal/api/graphql_version.rb +1 -1
  42. metadata +35 -2
  43. data/README.md +0 -24
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 128e83d1aa8d0fa5b9e4ce95f8bbd5f8bd4f219ea7d7a94dd18ec31225ac3ce9
4
- data.tar.gz: 7d79138b320e6ba09221bd79a4c341ef9dc039f4ccf6923567f3f4b36f657c27
3
+ metadata.gz: 90830a81a4ad762cf9a4a74e08d3898076e15f09ce677e78e7f0d5809c5517fb
4
+ data.tar.gz: 04c5d2c47000df72cbb2a3a705622ccc6c2a80340bae6745dc088b5bf5151d73
5
5
  SHA512:
6
- metadata.gz: 04cb9487e78e2f11b4c71572bc17915570a8638da67df94cfa27f0dfb3400d2c9f67bcaf25ac1b5bb61f86f1884f5af88b3adf961528ef8d3e5567607fcc1140
7
- data.tar.gz: 3036e3e2295a5a2eb5876e66b145d09f3fd1a96fc174234f77621b0cd553ad2e501dc4d9c1baf74077990d9ed03c77e9bb8c8858cec97005e7dbefbeb05bda45
6
+ metadata.gz: fee4494637c2fc0d591a0dccbbbb740cb3f99809be5205bf25be36f9d6d42ee3a956a06d2700f5baba2cb9bb8682abf4f7b31d792f84e84992b2c485ba605b1d
7
+ data.tar.gz: df0b9542b045c52224135f8dcaa50f9579ddcf9363e3ea7e774e1330a5b69227aac8134e66a1752586b7f4bba94ff63d46c7f7eefeec36df25bcba041d8a5c98
data/CHANGELOG.md CHANGED
@@ -2,13 +2,191 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
- ## [2.2.1] - 2026-09-30
5
+ ## [3.0.0] - 2026-09-29
6
+
7
+ **Major, not minor:** `Base::Page::DataField::ImageGallery#file_container_ids`/
8
+ `#file_container_ids=` are REMOVED (see the first entry under `Changed`, below) -- a real
9
+ public-interface removal, not merely an addition, so this is a major version bump under
10
+ the versioning convention (minor = additive/breaking-but-additive, major = removal).
11
+ `lib/ecoportal/api/graphql/forces/` also left this gem for `oscar/eco-forces` (see the
12
+ `Changed` entry below) -- NOT breaking for any rubygems.org consumer (never packaged in a
13
+ released version). `lib/ecoportal/api/graphql/dsl/` left earlier, for `oscar/eco-dsl-dialects`
14
+ (FAAP-106 L2, `0bb6cd2`) -- also never released, and (owner ruling) not itself re-announced
15
+ here since there is nothing to announce; only this gem's own release guard and the fragments
16
+ whose described code STAYED are recorded below. **`lib/ecoportal/api/graphql/versioning/`
17
+ is likewise never packaged** (owner ARCHITECTURE ruling, PERKS, same day) -- unlike `dsl/`/
18
+ `forces/`, the CODE stays in this repo for now (extraction to its own private gem is a
19
+ follow-up card); every `Versioning::*`/`TemplateRegistry`/`DiffProfile`/`ProfileRunner`
20
+ entry this release would otherwise have listed is dropped from this changelog entirely
21
+ (none of it has ever shipped in a released version either) -- see the one `Removed` line
22
+ below.
6
23
 
7
- ### Security
24
+ ### Added
25
+
26
+ - **Added:** `WorkflowCommand` builder for `moveSectionToStage` (new in the 2026-09-29 live schema):
27
+ `build(:moveSectionToStage, source_stage_id:, target_stage_id:, section_id:, anchorId:, anchorPosition:)`,
28
+ moving an existing section to another stage. `anchorPosition` is validated like the other anchor commands.
29
+
30
+ - Test/tooling: DSL-08 emitter equivalence acceptance test
31
+ (`spec/ecoportal/api/graphql/emitters_equivalence_spec.rb`) and a new pure
32
+ `Model::Template::CommandNormalizer` helper (`lib/.../model/template/command_normalizer.rb`)
33
+ that repositions placeholder/id tokens to position and deep-sorts command keys so batches
34
+ from different emitters can be compared for structural equivalence. Measures (does not yet
35
+ fix) 8 real divergences between `Builder::TemplateBuilder`, `Diff::CommandSynthesizer`,
36
+ `Model::Template::CommandSynthesis`, the `template_field_config` DSL dialect, and the
37
+ a downstream script repo's writers against a shared one-stage/one-section/one-Select-field/
38
+ one-force/one-binding fixture -- see `TEMPLATE-MODEL-DESIGN.md` section 8 for the full
39
+ divergence table and fix plan. No production behaviour changed by this fragment.
40
+
41
+ - **`FileUpload::Client#upload_image`/`#upload_all_images`** — uploads a local file into the
42
+ Image Gallery upload pipeline (`uploadImage` → a `TempImage`, NOT `uploadFile` →
43
+ `FileContainer`) and returns the `TempImage` id (the `sourceId` an `ImageInput` write
44
+ needs). Shares its presign + S3-POST step with `#upload`/`#upload_all` (same collision-proof
45
+ key convention, same field order) — only the registration mutation differs. Same
46
+ `Error` hierarchy, same `:signature`/`:storage`/`:register` hooks, same bounded-concurrency
47
+ batch semantics as the existing File-container upload. New: `Mutation::Image::Upload`
48
+ (wraps `uploadImage(input: ImagesUploadInput!)`), `Payload::ImagesUpload`,
49
+ `Base::TempImage`/`Model::TempImage` (`id`, `s3Key`, `error`, `complete`).
50
+ - **`Base::Page::DataField::ImageGallery#add_source_images`** — merge-safe writer for Image
51
+ Gallery fields (mirrors `FileField#file_container_ids=`'s kept-vs-new split): keeps every
52
+ currently-loaded image (echoed back as `{id:}` — `ImageGalleryInput.images` is a FULL
53
+ REPLACE server-side, same trap as `FileField#items`) and adds new ones (`{sourceId:,
54
+ weight:, fileName:}`, `sourceId` being a `TempImage` id from `#upload_image`), at
55
+ sequential weights starting at `#next_weight` (one past the current maximum — there is no
56
+ server-side auto-increment for `weight`). Also adds `#images`/`#image_ids`/`#next_weight`
57
+ readers.
58
+
59
+ - `Model::Template.load(api, id:)` / `Model::Template::Instance` — the mutable, "three
60
+ lines" editable template model (the repo's internal docs): `template = Model::Template.load(api, id:); template.fields.each
61
+ { |f| f.label = ... }; template.save!(api)`. Reconciles the gem's existing one-shot
62
+ `Builder::TemplateBuilder`/`Diff::CommandSynthesizer` command-emission layer with the
63
+ a downstream script repo's toolkit's proven staged/chunked write pattern
64
+ (`CommandBatchExecutor`, `TemplateFieldsRead`, `SectionWriter`/`FieldWriter`/
65
+ `SelectOptionWriter`/`ForceAndBindingWriter`), promoted here as a GENERIC gem primitive
66
+ rather than duplicated per org.
67
+ - `#as_commands` — diffs the in-memory tree against its load-time snapshot, ordered stage
68
+ -> section -> field -> option/config -> force -> helper -> binding, with a per-stage
69
+ `reorderSection` trailer.
70
+ - `#save!(api, simulate: true)` (default) returns the staged PLAN with no server call;
71
+ `simulate: false` executes it through `Builder::Template#update` (`updatePageTemplate` —
72
+ never `executeWorkflowCommands`, per `18_template_editor_save_path.md` section D.4's own
73
+ recommendation), chunked (`Model::Template::CommandGrouper`, a generic union-find grouping
74
+ that never splits a placeholder-minting command from a same-batch dependent), staged in
75
+ four phases (stage / section / field+option / force / helper+binding) with a live re-read
76
+ + positional/count-guard/cross-check id resolution between phases — any number of
77
+ stages/sections/fields/forces, not one template's shape.
78
+ - `#verify(api)` re-reads and reports per changed property `verified`/`mismatch`/`missing`
79
+ against the model's OWN current (post-save) state.
80
+ - `Builder::Template#update` / `Mutation::Template::Update#query` gained an optional
81
+ `client_mutation_id:` (omitted when not given — the pre-existing call shape is byte-
82
+ identical for every other caller) for `StagedExecutor`'s own per-chunk tracing id.
83
+
84
+ - `rewrite_field_labels`/`rewrite_field_tooltips` results now carry a `selection_digest`
85
+ (a sha256 hex digest over the sorted touched field ids + their before-values -- never the
86
+ values themselves) -- `verify_field_labels`/`verify_field_tooltips` gained an OPTIONAL
87
+ `expected_selection_digest` argument: when given and it differs from a fresh digest of
88
+ the current selection, the verify call refuses with `selection_drift: true, verified:
89
+ false` and both digests (hex only) instead of the usual buckets. Closes "apply requires
90
+ a paired verify over the SAME selection" pinning a PREDICATE, not a fixed set -- the live
91
+ field set (or a field's own value) can change between a rewrite call and a later verify
92
+ call (a field added, a label hand-edited, a force re-installed) with nothing to detect
93
+ it (eco-forces consumer review finding F-04). Fully backwards compatible: omitting
94
+ `expected_selection_digest` verifies against the current selection exactly as before.
95
+ See the repo's internal docs, "Selection digest".
96
+
97
+ ### Changed
8
98
 
9
- - Security republish of `2.2.0` -- backwards-compatible: removes customer and internal identifiers
10
- from shipped comments and this changelog. Packaging is unchanged: `2.2.0` already shipped only
11
- allowlisted files (`lib/**/*.rb` plus the named root docs).
99
+ - **BREAKING:** `Base::Page::DataField::ImageGallery#file_container_ids`/
100
+ `#file_container_ids=` are REMOVED — an Image Gallery image was never a `FileContainer`;
101
+ `ImageGalleryInput` has no `fileContainerIds` on the live schema (confirmed against the live
102
+ ecoPortal server source — `ImageGalleryInput` takes `images: [ImageInput]`, referencing a
103
+ NEW image by `sourceId`, a `TempImage` id from the separate `uploadImage` mutation, never a
104
+ `fileContainerId`). The old accessors read/wrote a doc key (`fileContainers`) the live API
105
+ never returns or accepts — any caller depending on them was already silently broken (see the
106
+ three characterization bugs closed below). Replaced by `#images`/`#image_ids`/
107
+ `#add_source_images` (see `Added`, above) — a caller previously doing
108
+ `field.file_container_ids = [...]` should now upload via
109
+ `FileUpload::Client#upload_image` and call `field.add_source_images([{source_id:,
110
+ file_name:}, ...])` instead.
111
+
112
+ - `imageGalleryField` (`Fragment::Pages::CommonPageUnion`) now selects `id`/`weight` on each
113
+ `images` item — both were previously omitted entirely, which is why no correct reader was
114
+ possible before this fix (see the `Fixed` entry below).
115
+ - `fileName`/`fileSize` on an Image Gallery image are confirmed NULLABLE server-side even on
116
+ a successful upload (a documented upstream platform bug, per the live schema's own field
117
+ comment) — any comparison built on either must treat a nil on either side as "not a
118
+ confident match", never a positive one. Documented on
119
+ `Base::Page::DataField::ImageGallery`'s own header.
120
+
121
+ - **Changed (behaviour):** `Base::Page::DataField::ImageGallery#as_input` now echoes a KEPT
122
+ (currently-existing, untouched) image with its FULL `ImageInput` field set — `id`,
123
+ `sourceId` (read back from `uploadId`), `weight`, `caption`, `fileName`,
124
+ `sensitiveContent`, `inaccurateDescription`, `inaccurateExtractedText` — instead of a bare
125
+ `{id:}`. `ImageGalleryInput.images` is a full replace server-side
126
+ (`assign_attributes_service.rb#set_image_gallery_attrs` → `format_removed_ids`): a bare
127
+ `{id:}` is read by the server as "clear every other attribute on this image", not "leave
128
+ it as it is" — the previous shape silently reset caption/moderation flags to their
129
+ defaults on every write that merely appended a new photo alongside existing ones.
130
+ `#add_source_images` now keeps each existing image's FULL doc entry (not collapsed to a
131
+ bare `{id:}`) so this echo has the data to build from. `#add_source_images`'s own
132
+ kept-vs-new merge semantics (weight = current max + 1, sequential) are unchanged.
133
+
134
+ - `lib/ecoportal/api/graphql/forces/` (dry-run docking, the L1 force-representation
135
+ reader/bridge/history slice, the conformance harness -- 14 files) is REMOVED from
136
+ this gem and moves to `oscar/eco-forces` (owner ruling R-2026-09-28-64 D-19:
137
+ eco-forces is the most sensitive IP in this stack, so the GraphQL client adapter
138
+ code that docks directly into it gets its own repo, same reasoning as the earlier
139
+ `dsl/` -> `eco-dsl-dialects` move). NOT BREAKING for rubygems.org consumers: this
140
+ tree was never published in any released version of this gem -- confirmed 0 files
141
+ under this path in every release through 2.2.0 (measured via `gem fetch` +
142
+ `Gem::Package#contents`, per the release-guard's own header). The only consumer this
143
+ could ever have reached is a repo-checkout-level `require
144
+ "ecoportal/api/graphql/forces"` (opt-in, never part of the main require chain, only
145
+ resolvable under the now-removed `Gemfile.forces`). `lib/ecoportal/api/graphql/
146
+ forces/CLAUDE.md` stays as a short pointer (same pattern as `dsl/CLAUDE.md`). The
147
+ release guard (`scripts/audit/dsl_release_guard.rb`) keeps its own
148
+ `lib/ecoportal/api/graphql/forces/` protected-prefix entry, now lifted by this MR's
149
+ own `docs/audits/rulings/forces-licence-ruling.md` -- independent of `dsl/`'s own
150
+ entry, which stays blocked until a separate ruling exists.
151
+ - `Gemfile.forces` removed entirely -- nothing else in this gem required eco-forces
152
+ (confirmed: a full-repo grep for `eco/forces`/`eco-forces`/`Eco::Forces` outside the
153
+ removed tree found only doc/tooling mentions unrelated to any require path). Its
154
+ `.rubocop.yml` exclude entry removed with it.
155
+ - `tools/forces-conformance/` and `tools/forces-l1/` removed -- both existed only to
156
+ drive the now-removed lib tree (their own `require_relative`s pointed directly into
157
+ it); both moved to `oscar/eco-forces` verbatim instead (same commit/MR as the tree
158
+ itself).
159
+ - The six `changelog.d/` fragments describing this tree's own prior history
160
+ (`added-forces-conformance-harness.md`, `added-forces-docking-dry-run.md`,
161
+ `added-forces-l1-dump-adapter.md`, `added-forces-l1-history.md`,
162
+ `added-forces-l1-reader-bridge.md`, `fixed-forces-parity-rerun-protocol-run.md`) are
163
+ DELETED from this gem's changelog.d and migrated, reworded only where a path/name
164
+ changed, into `oscar/eco-forces`'s own `CHANGELOG.md` `[Unreleased]` section -- this
165
+ gem never shipped a tagged release carrying any of them, so nothing is lost from
166
+ this gem's own history by moving them. `security-faap-906b-guard-forces.md` (the
167
+ guard itself, not the moved code) stays here, unchanged.
168
+
169
+ **Migration:** if you were requiring `ecoportal/api/graphql/forces` at the
170
+ repo-checkout level (never possible via the published gem, see above), add
171
+ `eco-forces` to your own Gemfile instead -- see that gem's README, "GraphQL client
172
+ adapters (moved from ecoportal-api-graphql 3.0.0)".
173
+
174
+ - **Changed (behaviour):** `Input::Page::Update.from_model` now defaults `client_mutation_id:`
175
+ to a fresh `SecureRandom.uuid` per call instead of an omitted empty string — every
176
+ `updatePage` mutation this input builds is now traceable by default (correlation only; the
177
+ server does NOT dedupe mutations on `clientMutationId`, so this buys no idempotency — a
178
+ retried call with the same id still re-applies the write). Pass an explicit
179
+ `client_mutation_id:` to correlate several calls under one value, or `''`/`nil` to omit the
180
+ key entirely (unchanged).
181
+
182
+ - **Changed** `scripts/release_smoke_check.rb` (release tooling, not packaged in the gem): the
183
+ packaging gate now honours per-gem exceptions declared in a `.release-smoke-allow` file at the
184
+ gem root — one EXACT packaged path per line, no globs — plus a `RELEASE_SMOKE_EXTRA_ALLOWED`
185
+ env override for one-off runs. Needed because the shared gate hardcoded `lib/**/*.rb` and
186
+ refused eco-helpers 3.3.2, whose three JSON data files are loaded at require time and are
187
+ allowlisted by its own gemspec and packaging spec. Exceptions are themselves gated: a declared
188
+ path missing from the package FAILS (stale declarations cannot rot silently), and a declared
189
+ `.json` file must parse. Proven red-then-green against the real eco-helpers-3.3.2.gem artifact.
12
190
 
13
191
  ### Fixed
14
192
 
@@ -18,11 +196,229 @@ All notable changes to this project will be documented in this file.
18
196
  longer selects them. The model accessors are kept, deprecated, and now always return `nil`, so
19
197
  callers do not break. Affects every earlier version, including 2.2.0.
20
198
 
21
- ### Added
199
+ - `AuthService::InstanceMethods#session_token_renewed` read the wrong hash key when no
200
+ explicit `refresh_token:` was given -- `body['resfresh_token']` (typo) instead of
201
+ `body['refresh_token']` -- so a session refresh attempted without an explicit token
202
+ always silently returned `nil` instead of renewing, even though the server's own
203
+ response body carried a real `refresh_token`. Fixed at
204
+ `lib/ecoportal/api/common/graphql/auth_service.rb`.
205
+ Zero specs existed for `AuthService` before this fix; `spec/ecoportal/api/common/
206
+ graphql/auth_service_spec.rb` now covers `#session_token` (happy path, the
207
+ auto-renew window, `auto_renew: false`, and a failing `session_token_data` call),
208
+ `#session_token_renewed` (explicit `refresh_token:`, reading it from the body, a
209
+ missing key, and a failing refresh call), and `#token_renew?`'s own boundary
210
+ (`TOKEN_AUTORENEW * 60` seconds, exactly at / one second inside / one second
211
+ outside) -- with a plain-Ruby fake HTTP client, no network, no new gem dependency.
212
+
213
+ - `Model::Template::CommandSynthesis` now emits `stageId` on `editFieldConfiguration` when
214
+ the owning template is phased (a new `Instance#phased?`, derived from the read's own
215
+ `page['__typename']`) -- `Field` always carries a `#stage` reference, but the command never
216
+ threaded it through, so editing a field via `Model::Template#save!` on a phased page would
217
+ omit a schema-required disambiguator and 500 server-side (found measuring DSL-08's emitter
218
+ equivalence acceptance test against the DSL dialect's own tested binding and the downstream
219
+ `FieldWriter`, both of which already got this right). `stageId` is still correctly OMITTED
220
+ (never sent as an explicit `null`) when the template is non-phased.
221
+
222
+ - `Ecoportal::API::Common::GraphQL::HttpClient#base_request` (the only place in this gem's
223
+ dependency chain that talks to the server via `http.rb`; every live GraphQL query goes
224
+ through it via `Logic::BaseQuery#graphql_query` -> `client.http_client.execute`, and the
225
+ OAuth token POST in `AuthService#auth_http_client` reuses the same method) called
226
+ `.timeout(read: READ_TIMEOUT, write: WRITE_TIMEOUT)` without a `connect:` value. http.rb's
227
+ `HTTP::Timeout::PerOperation` (`lib/http/timeout/per_operation.rb`) defaults any timeout key
228
+ NOT given to 0.25 seconds, so `@connect_timeout` stayed at that 0.25s library default even
229
+ though read/write were correctly set to 90s. `connect_ssl` (the TLS handshake) uses
230
+ `@connect_timeout` via `rescue_readable`/`rescue_writable`, whose error text is
231
+ unconditionally "Read timed out after #{@read_timeout} seconds" -- so a slow TLS handshake
232
+ against the EU instance surfaced as `HTTP::TimeoutError: Read timed out after 0.25 seconds`
233
+ even though the actual `@read_timeout` was 90 the whole time. Live-confirmed 2026-09-08 via
234
+ backtrace: `connect_ssl` -> `start_tls` (`http/connection.rb`) -> `Common::Client#post` ->
235
+ `AuthService#session_token_data` -> `Common::GraphQL::Client#initialize`. Added a
236
+ `CONNECT_TIMEOUT = 30` constant alongside `READ_TIMEOUT`/`WRITE_TIMEOUT` and pass
237
+ `connect: CONNECT_TIMEOUT` in the same `.timeout(...)` call. Read and write were never the
238
+ problem and are unchanged.
239
+
240
+ - `Ecoportal::API::Common::GraphQL::HttpClient#base_request` (the only place in this gem's
241
+ dependency chain that talks to the server via `http.rb`; every live GraphQL query goes
242
+ through it via `Logic::BaseQuery#graphql_query` -> `client.http_client.execute`, and the
243
+ OAuth token POST in `AuthService#auth_http_client` reuses the same method) built a plain,
244
+ non-persistent `HTTP` client. http.rb sends `Connection: close` on every request from a
245
+ non-persistent client (`http/client.rb`: `default_options.persistent? ? KEEP_ALIVE : CLOSE`).
246
+ Live-confirmed 2026-09-08: `eu.live.ecoportal.com` closes any HTTP/1.1 request carrying that
247
+ header without ever sending a response -- immediately, or after ~61s -- which surfaced in
248
+ Ruby as `OpenSSL::SSL::SSLError: SSL_read: unexpected eof while reading`. The same Ruby stack
249
+ with a persistent (keep-alive) client works (`HTTP.persistent("https://eu.live.ecoportal.com")
250
+ .post(...)` -> 200 in 1.9s); Sydney (`live.ecoportal.com`) is fine either way. `base_request`
251
+ now returns a client made persistent to the configured host (`HTTP.persistent(base_url,
252
+ timeout: KEEP_ALIVE_TIMEOUT)`, new `KEEP_ALIVE_TIMEOUT = 5` constant) before chaining the
253
+ existing `.accept(:json).timeout(...)`. Confirmed against the installed http.rb 5.3.1 source
254
+ that this is safe: one `HttpClient` is bound to one host, so `HTTP::StateError` (raised only
255
+ on a cross-origin request against a persistent client, `client.rb#verify_connection!`) cannot
256
+ occur here; and an expired or dead persistent connection is transparently closed and
257
+ reconnected on the next request (`verify_connection!` checks `@connection.expired?` /
258
+ `keep_alive?` and calls `close`, then `perform` lazily re-opens via
259
+ `@connection ||= HTTP::Connection.new(...)`), with any resulting `HTTP::ConnectionError`
260
+ already covered by the inherited `Common::Client` retry pipeline. `execute` and the rest of
261
+ the retry/throttle pipeline are unchanged.
262
+
263
+ - **Fixed:** `imageGalleryField` (`Fragment::Pages::CommonPageUnion`) now also selects
264
+ `sensitiveContent`, `inaccurateDescription`, `inaccurateExtractedText` on each `images`
265
+ item, alongside the pre-existing `id`/`weight`/`downloadUrl`/`caption`/`fileName`/
266
+ `fileSize`/`uploadId` — every `ImageInput`-echoable attribute is now read in one query, so
267
+ `#kept_image_input`'s full-field echo (see the `Changed` entry above) never needs a
268
+ follow-up read. `fileName`/`fileSize` remain documented NULLABLE even on a successful
269
+ upload (upstream platform bug) — never treat a nil on either as a confident match.
270
+
271
+ - `Base::Page::DataField::ImageGallery`'s reader closes all three defects
272
+ `image_gallery_characterization_spec.rb` documented: BUG-3a (reader keyed on the wrong doc
273
+ key, `fileContainers` instead of `images` — see `Changed`, above, for the fragment fix this
274
+ needed first), BUG-3b (the fragment omitted `images[].id`, so no correct reader was
275
+ possible), and BUG-3c (a pure read injected a phantom `fileContainers` key into `doc`) — the
276
+ new reader is a plain `doc['images']` accessor (no `passarray`), so a pure read no longer
277
+ mutates `doc` at all, a stronger fix than the prior array-diff no-op (MR !106) gave the old
278
+ reader.
279
+
280
+ - `Common::GraphQL::Client#schema` no longer lets graphql-ruby's "Input Object types must
281
+ have arguments" warning leak out for the argument-less INPUT_OBJECT types the live
282
+ ecoPortal schema actually ships (confirmed today: `SupervisedPeopleFieldValuesGeneratorInput`,
283
+ `DiscreteMetadataFilterWidgetInput`, `DateFilterWidgetInput`, `PeopleFilterWidgetInput`,
284
+ `ByUserAnalysisInput`, `ByTypeAnalysisInput`, `ByRelativeStatusAnalysisInput`,
285
+ `TemplatesAnalysisInput`, `RestorePageOperationInput`, `UnarchiveOperationInput`,
286
+ `ArchiveOperationInput`, `DeleteFilesOperationInput` -- 12 types, 0 after the fix).
287
+ `GraphQL::Schema::Loader` (used by `GraphQL::Schema.from_introspection`, which
288
+ `Graphlient::Schema` / `GraphQL::Client.load_schema` call to build our client-side schema
289
+ from the server's raw introspection JSON) defines one `GraphQL::Schema::InputObject`
290
+ subclass per INPUT_OBJECT type but never calls `has_no_arguments(true)` on the ones whose
291
+ `inputFields` came back empty -- so graphql-ruby warns (and, in a future version, will
292
+ raise) the first time anything asks such a type for its arguments, which happens whenever
293
+ the loaded schema is re-serialized (`schema.to_json` / `to_definition`), as eco-helpers'
294
+ `-graphql-schema` use case does. The live server never warns because its own Ruby classes
295
+ are defined with real argument lists (or `has_no_arguments(true)` already set) -- only the
296
+ client-side schema rebuilt purely from introspection data loses that annotation, so the fix
297
+ belongs here rather than in graphlient/graphql-client/graphql-ruby or in eco-helpers.
298
+ `#schema` now walks the freshly loaded schema's types once and calls the library's own
299
+ `has_no_arguments(true)` on every INPUT_OBJECT with no arguments, leaving types that do have
300
+ arguments untouched. No pre-processing of the introspection JSON, no schema shape change.
301
+
302
+ - **Fixed:** `updatePage` on a PHASED page has been observed to 500 when `input.stageId` is
303
+ missing on an Image Gallery / File field write (live-diagnosed, page
304
+ a customer's phased page) — `Input::Page::Update.from_model` now DERIVES a default
305
+ `stageId` from the owning stage of the data fields being changed in the same call (walks
306
+ `model.stages`/`Stage#stage_field_ids`), for both `dataFields.updates`/`.additions` and
307
+ `.deletions`. A `BasicPage` (no `#stages` at all) is unaffected — `stageId` is omitted
308
+ entirely, never sent as an explicit `null`. Explicit `stage_id:` still overrides the
309
+ derivation. If the fields being updated in ONE call span more than one stage,
310
+ `from_model` now raises a clear `ArgumentError` instead of guessing which stage's id to
311
+ send — split such a change into one call per stage, in stage order, re-fetching the page
312
+ (fresh `patchVer`) between calls. Corrects a stale doc comment on `#require_stage!` that
313
+ claimed "a plain field update ... needs no stage" — true only for a `BasicPage`.
314
+
315
+ - `Ecoportal::API::GraphQL::DSL::TemplateFieldConfig::Generator.declare_rewrite!`'s
316
+ `rewrite_field_<x>s`/`verify_field_<x>s` set-shaped verb pair now declares `acts_on` as
317
+ the UNION of every element noun type it can reach (`template` AND `data_field` -- was
318
+ `template` only). A set-shaped verb reaches EVERY `DataField` matching its pattern, not
319
+ just the template it is invoked against; under-declaring `acts_on` would under-approximate
320
+ a future eco-dsl-core strict-mode required-class set (found by the eco-forces consumer
321
+ review of this convention, finding F-07 -- `eco-dsl-core` `docs/reviews/2026-09-22-set-
322
+ shaped-verb-forces-consumer-review.md`).
323
+
324
+ - `spec/ecoportal/api/graphql/diff/{version_diff_spec,command_synthesizer_spec,deploy_spec,
325
+ version_diff_typed_config_spec,version_diff_modalities_spec}.rb` each duplicated the same
326
+ `fixture(name)` helper calling `File.read(path)` with no `encoding:` -- under a non-UTF-8
327
+ ambient locale (`LANG=C LC_ALL=C`, no `RUBYOPT` encoding flags) this raised
328
+ `Encoding::InvalidByteSequenceError` inside `JSON.parse` on the first non-ASCII byte in a
329
+ fixture, before a single document was even parsed (35 spec failures reproduced this way).
330
+ JSON is always UTF-8 per its own spec (RFC 8259 SS8.1), never a guess. Extracted the
331
+ duplicated helper into a single shared `Support.template_fixture` (`spec/support/
332
+ template_fixtures.rb`, `encoding: 'UTF-8'`); the five spec files now delegate to it. Spec
333
+ code only -- no `lib/` change. Verified: `LANG=C LC_ALL=C bundle exec rspec` and the normal
334
+ `bundle exec rspec` both green (2069 examples, 0 failures, 45 pending either way).
335
+
336
+ - `Mutation::Template::Create` / `Builder::Template#create` now accept an optional
337
+ `register_id:`, sent as `CreatePageTemplateInput.registerId` (verified against the live SDL,
338
+ 2026-09-04 — `registerId: ID` is an INPUT FIELD, because
339
+ `Mutations::BaseMutation < GraphQL::Schema::RelayClassicMutation` folds the backend's
340
+ `argument :register_id` into the generated input object). Without it a template create is
341
+ **unauthorizable**, not merely unbound: the backend resolves a page's registers by tag
342
+ (`NewEp::Pages::AssociatedRegisters` — org registers whose `filter_tags` are a subset of the
343
+ page's `combined_tags`) and `RegisterTemplatePermissionChecker` returns `false` outright when
344
+ that set is empty. A template created with no `registerId` carries no `base_tags`, so it
345
+ matches zero registers, so the check fails no matter what the account is granted — surfacing
346
+ as the misleading `You are not authorized to perform: edit_template_basic_settings`.
347
+ `Pages::Templates::CreateForm#bind_register!` is what closes the hole, setting
348
+ `page.base_tags = register.filter_tags` before the command batch runs.
349
+ A nil `register_id:` is OMITTED, never sent as an explicit null, so every existing call site
350
+ produces the byte-identical input it produced before.
351
+
352
+ - `tools/template_yaml/cli.rb --delta` now wires `reviewer: Review.method(:call)` into
353
+ `Delta.call`, so the printed JSON always carries a `review_findings` key (`{'before' => [...],
354
+ 'after' => [...]}` of Review finding triples), matching the fixture ground truth the
355
+ ep-rovo-qa delta-verification agent reads. It also merges a top-level **`warnings`** key
356
+ (`Delta::Result#warnings`) into the printed JSON -- pairing-key collisions, duplicate force
357
+ names, rebuilt-field counts -- so the `colliding_keys` false-pairing trap reaches the consumer
358
+ instead of being silently dropped. `Delta::Result#payload` itself is unchanged (it stays
359
+ byte-for-byte comparable to the Python projector's JSON); `warnings` is added by the CLI only,
360
+ on top of the payload, for `--delta` output specifically.
361
+ - Documented, in `cli.rb`'s header and `id_guard.rb`'s header, that `IdGuard`'s `HEX24` check is
362
+ a plain regex (`/\A[0-9a-f]{24}\z/`) with no ObjectId timestamp/counter decoding and no
363
+ uniqueness check beyond it -- confirming `md5(readable-name)[:24]` as an accepted convention for
364
+ synthetic fixture ids (a hex-24 string of either provenance passes identically).
22
365
 
23
- - **Added:** `WorkflowCommand` builder for `moveSectionToStage` (new in the 2026-09-29 live schema):
24
- `build(:moveSectionToStage, source_stage_id:, target_stage_id:, section_id:, anchorId:, anchorPosition:)`,
25
- moving an existing section to another stage. `anchorPosition` is validated like the other anchor commands.
366
+ ### Removed
367
+
368
+ - `versioning/` (git-core diff/versioning) is a perk -- not packaged in the public gem;
369
+ available from a private gem in a follow-up.
370
+
371
+ ### Security
372
+
373
+ - **Security (release gate):** `scripts/release_smoke_check.rb` now also gates packaged CONTENT,
374
+ not only which files ship. Every packaged text file is scanned for object ids, the private
375
+ source-host name, internal documentation paths, internal repository names, and a local
376
+ (never-shipped) denylist of customer names; hits fail the release unless excused by an exact
377
+ line in `.release-content-allow`. The gem now ships a short public `README.gem.md` instead of
378
+ the repository's developer README, and code comments and this changelog were reworded to
379
+ remove internal identifiers.
380
+
381
+ - New `bundler-audit` CI job (MIDW-909): runs `bundle exec bundle-audit check
382
+ --update` on every merge-request and branch pipeline, `allow_failure: false`.
383
+ `bundler-audit` (`~> 0.9`) added to the `Gemfile` (repo-local CI tool, not a
384
+ gemspec dependency).
385
+ - Fixed the "Insecure Source URI" finding the job first reported: the optional
386
+ `anthropic` dependency (only needed for `SearchConf::AIGenerator` live tests)
387
+ no longer has its own `source 'http://rubygems.org' do ... end` block -- it is
388
+ folded into the file's top-level `https://rubygems.org` source (owner ruling
389
+ 2026-09-29, option a). The block existed to bypass local SSL inspection on
390
+ some developer machines (e.g. Norton Antivirus); that workaround now belongs
391
+ on the machine (point `SSL_CERT_FILE` at the OS certificate store's CA
392
+ bundle), not in the dependency source URI. `bundle exec bundle-audit check
393
+ --update` now reports no vulnerabilities.
394
+
395
+ - Added a release guard, `scripts/audit/dsl_release_guard.rb`: no gem build that
396
+ packages a path under `lib/ecoportal/api/graphql/dsl/` may be published (CI job
397
+ `dsl-release-guard` on every pipeline; `rake release:build`, before the smoke check)
398
+ until `docs/audits/rulings/dsl-licence-ruling.md` carries a `RULED:` line. Stopgap
399
+ while the DSL licence question (FAAP-906) is open; does not change `LICENSE`, the
400
+ gemspec licence field, or `spec.files`.
401
+
402
+ - Extended the release guard, `scripts/audit/dsl_release_guard.rb` (FAAP-906): the
403
+ protected prefixes are now a list, each lifted independently by its own ruling
404
+ file. No gem build that packages a path under `lib/ecoportal/api/graphql/dsl/` OR
405
+ `lib/ecoportal/api/graphql/forces/` may be published (CI job `dsl-release-guard`;
406
+ `rake release:build`) until THAT prefix's own ruling file
407
+ (`docs/audits/rulings/dsl-licence-ruling.md` or
408
+ `docs/audits/rulings/forces-licence-ruling.md`) carries a `RULED:` line --
409
+ R-2026-09-28-64 (D-19): eco-forces is the most sensitive IP. Measured: the
410
+ published 2.2.0 gem ships zero files under either prefix. Does not change
411
+ `LICENSE`, the gemspec licence field, or `spec.files`.
412
+
413
+ - Extended the release guard, `scripts/audit/dsl_release_guard.rb`, with a THIRD
414
+ protected prefix, `lib/ecoportal/api/graphql/versioning/` (owner ARCHITECTURE
415
+ ruling 2026-09-29: dsl dialects, forces, and git-core diff/versioning are perks,
416
+ not core). No gem build that packages a path under this prefix may be published
417
+ until `docs/audits/rulings/versioning-licence-ruling.md` carries a `RULED:` line.
418
+ Unlike the dsl/forces rulings, this ruling file records that the prefix stays
419
+ blocked by design -- extraction to a private gem is a follow-up card, not
420
+ something this ruling completes. Measured: 0 packaged paths under all three
421
+ prefixes. Does not change `LICENSE`, the gemspec licence field, or `spec.files`.
26
422
 
27
423
  ## [2.2.0] - 2026-09-02
28
424
 
data/README.gem.md ADDED
@@ -0,0 +1,53 @@
1
+ # ecoportal-api-graphql
2
+
3
+ A Ruby client for the ecoPortal GraphQL API: typed models for pages, templates, data fields and
4
+ registers, query and mutation helpers, and the workflow command bus for granular template edits.
5
+
6
+ This is the README shipped inside the published gem. The source repository keeps its own
7
+ developer README.
8
+
9
+ ## Install
10
+
11
+ ```ruby
12
+ gem 'ecoportal-api-graphql', require: %w[ecoportal/api-graphql]
13
+ ```
14
+
15
+ ```
16
+ $ bundle
17
+ # or: $ gem install ecoportal-api-graphql
18
+ ```
19
+
20
+ ## Requirements
21
+
22
+ | | version |
23
+ |---|---|
24
+ | Ruby | `>= 3.2.2` |
25
+ | `ecoportal-api` | `~> 0.10, >= 0.10.17` |
26
+ | `ecoportal-api-v2` | `~> 3.3, >= 3.3.5` |
27
+ | `graphlient` | `>= 0.9.0, < 0.10` |
28
+
29
+ ## Example: load and edit a template
30
+
31
+ ```ruby
32
+ api = Ecoportal::API::GraphQL.new(email: ..., pass: ..., org_id: ...)
33
+ template = Ecoportal::API::GraphQL::Model::Template.load(api, id: template_page_id)
34
+ template.fields.each { |f| f.label = "#{f.label} (reviewed)" }
35
+ template.save!(api, simulate: false) # simulate: true is the default -- inspect first
36
+ ```
37
+
38
+ `save!` simulates by default: it reports the commands it would send without writing anything.
39
+ Pass `simulate: false` to apply them.
40
+
41
+ ## Optional extensions
42
+
43
+ Some capabilities are distributed separately as optional extensions and are not part of this
44
+ gem. The gem works fully without them; when an extension is installed, it is picked up
45
+ automatically.
46
+
47
+ ## Changes
48
+
49
+ See `CHANGELOG.md`, shipped alongside this file.
50
+
51
+ ## Licence
52
+
53
+ MIT -- see `LICENSE`.
@@ -25,7 +25,7 @@ module Ecoportal
25
25
  def session_token_renewed(host: server, refresh_token: nil)
26
26
  unless refresh_token
27
27
  return unless (body = session_token_data(host: host))
28
- return unless (refresh_token = body['resfresh_token'])
28
+ return unless (refresh_token = body['refresh_token'])
29
29
  end
30
30
 
31
31
  session_refresh_token_data(
@@ -56,12 +56,50 @@ module Ecoportal
56
56
  @org_id || fetch_env_required('ORGANIZATION_ID')
57
57
  end
58
58
 
59
+ # Graphlient/graphql-client build the client-side schema from the server's raw
60
+ # introspection JSON (GraphQL::Schema.from_introspection -> GraphQL::Schema::Loader).
61
+ # That loader defines one GraphQL::Schema::InputObject subclass per INPUT_OBJECT type,
62
+ # but it never calls `has_no_arguments(true)` on the ones whose `inputFields` came back
63
+ # empty. graphql-ruby then warns (and, in a future version, raises) the first time
64
+ # anything asks such a type for its arguments -- which happens whenever this schema is
65
+ # re-serialized (e.g. `schema.to_json`/`to_definition`, as the `-graphql-schema` use
66
+ # case in eco-helpers does). The live server never emits this warning because its own
67
+ # schema classes are defined directly in Ruby with real argument lists (or with
68
+ # `has_no_arguments(true)` already set) -- it is only the client-side schema, rebuilt
69
+ # purely from introspection data, that loses that annotation.
70
+ #
71
+ # Patching the built classes here (rather than pre-processing the introspection JSON)
72
+ # keeps the schema's shape untouched and uses graphql-ruby's own documented escape
73
+ # hatch for this exact situation.
74
+ def schema
75
+ mark_argumentless_input_objects!(super)
76
+ end
77
+
59
78
  private
60
79
 
61
80
  def url
62
81
  base_url = Ecoportal::API::Common::GraphQL::HttpClient.base_url(host)
63
82
  "#{base_url}/api/#{org_id}/#{ENDPOINT_PATH}"
64
83
  end
84
+
85
+ def mark_argumentless_input_objects!(loaded_schema)
86
+ return loaded_schema if @argumentless_input_objects_marked
87
+
88
+ graphql_schema_types(loaded_schema).each_value do |type|
89
+ next unless type.respond_to?(:kind) && type.kind.input_object?
90
+ next unless type.respond_to?(:has_no_arguments) && type.respond_to?(:any_arguments?)
91
+
92
+ type.has_no_arguments(true) unless type.any_arguments?
93
+ end
94
+ @argumentless_input_objects_marked = true
95
+
96
+ loaded_schema
97
+ end
98
+
99
+ def graphql_schema_types(loaded_schema)
100
+ target = loaded_schema.respond_to?(:graphql_schema) ? loaded_schema.graphql_schema : loaded_schema
101
+ target.types
102
+ end
65
103
  end
66
104
  end
67
105
  end
@@ -96,9 +96,11 @@ module Ecoportal
96
96
  end
97
97
  end
98
98
 
99
- ENDPOINT_PATH = 'external/graphql'.freeze
100
- READ_TIMEOUT = 90
101
- WRITE_TIMEOUT = 90
99
+ ENDPOINT_PATH = 'external/graphql'.freeze
100
+ CONNECT_TIMEOUT = 30
101
+ READ_TIMEOUT = 90
102
+ WRITE_TIMEOUT = 90
103
+ KEEP_ALIVE_TIMEOUT = 5
102
104
 
103
105
  attr_reader :host, :version
104
106
 
@@ -137,6 +139,36 @@ module Ecoportal
137
139
 
138
140
  # Creates a HTTP object adding the `X-ApiKey` or `X-ECOPORTAL-API-KEY` param to the header, depending on the API version.
139
141
  # @note It configures HTTP so it only allows body data in json format.
142
+ # @note This is the ONLY place in the gem's dependency chain that talks to the
143
+ # server via http.rb (`require 'http'`, pulled in by `Ecoportal::API::Common::Client`
144
+ # from the `ecoportal-api` gem) -- every live GraphQL query goes through `#execute`
145
+ # below, called from `Logic::BaseQuery#graphql_query` via `client.http_client.execute`,
146
+ # and `AuthService#auth_http_client` (`version: 'http'`) reuses this same
147
+ # `base_request` for the OAuth token POST. `Common::GraphQL::Client` (graphlient) is a
148
+ # separate, unrelated path: it never reaches http.rb at all.
149
+ #
150
+ # Without an explicit `connect:`, http.rb's `HTTP::Timeout::PerOperation` falls back
151
+ # to its own default of 0.25s for CONNECT even though read/write were already given
152
+ # explicitly (per_operation.rb: `options.fetch(:connect_timeout, CONNECT_TIMEOUT)`,
153
+ # `CONNECT_TIMEOUT = 0.25`). `connect_ssl` (`http/timeout/per_operation.rb`) uses
154
+ # that same `@connect_timeout` for the TLS handshake via `rescue_readable`/
155
+ # `rescue_writable` (`http/timeout/null.rb`) -- whose error text is unconditionally
156
+ # "Read timed out after #{@read_timeout} seconds", so a slow TLS handshake on the EU
157
+ # instance surfaced as `HTTP::TimeoutError: Read timed out after 0.25 seconds`
158
+ # even though `@read_timeout` itself was correctly 90 the whole time. Live-confirmed
159
+ # 2026-09-08 via backtrace: `connect_ssl` -> `start_tls` (`http/connection.rb`) ->
160
+ # `Common::Client#post` -> `AuthService#session_token_data` ->
161
+ # `Common::GraphQL::Client#initialize`. Read/write were never the problem; only
162
+ # connect was missing.
163
+ # @note The client is made PERSISTENT (`HTTP.persistent`) to `base_url`, not a
164
+ # plain one-shot client. Confirmed live 2026-09-08: eu.live.ecoportal.com
165
+ # closes an HTTP/1.1 request carrying `Connection: close` -- what a
166
+ # non-persistent http.rb client always sends -- without ever responding. A
167
+ # persistent client sends `Connection: keep-alive` and reuses the TCP/TLS
168
+ # connection instead, which the SAME Ruby stack proved fine against EU. This
169
+ # also matches Cloudflare keep-alive hygiene; Sydney is fine either way. One
170
+ # `HttpClient` is bound to one `host`, so `HTTP::StateError` (raised only on a
171
+ # cross-origin request against a persistent client) cannot occur here.
140
172
  # @return [HTTP] HTTP object.
141
173
  def base_request
142
174
  @base_request ||=
@@ -151,9 +183,10 @@ module Ecoportal
151
183
  HTTP.headers('Authorization' => "Bearer #{session_token(host: host)}")
152
184
  end.then do |request|
153
185
  request ||= HTTP
154
- request.accept(:json).timeout(
155
- read: READ_TIMEOUT,
156
- write: WRITE_TIMEOUT
186
+ request.persistent(base_url, timeout: KEEP_ALIVE_TIMEOUT).accept(:json).timeout(
187
+ connect: CONNECT_TIMEOUT,
188
+ read: READ_TIMEOUT,
189
+ write: WRITE_TIMEOUT
157
190
  )
158
191
  end
159
192
  end