Skip to main content

ignition_core/client/
mod.rs

1//! The gateway HTTP seam: a coarse [`GatewayApi`] trait so actions never
2//! touch reqwest types, plus the production [`ReqwestGatewayApi`].
3//!
4//! LOCKED: the trait uses `async_trait` (research Open Question 2,
5//! resolved) — dyn-compatible today, ubiquitous. The trait stays COARSE —
6//! one method per capability, not per endpoint — so Phase 2 grows it
7//! without churn.
8//!
9//! Auth-header rule (verified against a live 8.3.6 gateway, 02-RESEARCH
10//! §Auth Model): a token credential sends `X-Ignition-API-Token`; a basic
11//! credential sends `Authorization: Basic <b64>`; NEVER both — enforced by
12//! a match in [`ReqwestGatewayApi::apply_auth`], the ONE place
13//! [`Secret::expose`] is called outside the secret module (the
14//! grep-auditable redaction boundary; CORE-02).
15//!
16//! Basic is loudly demoted there: valid Basic credentials → 401 on every
17//! 8.3 `/data` route (verified), so each use warns — never silently
18//! retried. Note gateway-info itself DOES require auth under 8.3 default
19//! security (header-less → 401, re-verified live 2026-08-21 — the 83-api
20//! collection's `auth: none` tag does not hold); a `None` credential
21//! proceeds header-less and classifies the answer.
22//!
23//! Redirects are never followed (`Policy::none()`): an uncommissioned
24//! gateway 302s EVERYTHING to `/welcome` and the default follow would
25//! render the wizard's HTML as a 200 (02-RESEARCH Pitfall 6). The 3xx is
26//! classified by [`classify`] instead.
27//!
28//! Every request runs the pipeline: build URL → apply auth (opt-in) →
29//! send (transport error → `Network`) → [`classify`] → parse the body.
30//! Nothing ever calls `.json()` on a response that skipped `classify()`.
31
32use std::path::Path;
33use std::time::Duration;
34
35pub mod adopt;
36pub mod apicall;
37pub mod backup;
38mod classify;
39pub mod connections;
40pub mod diagnostics;
41pub mod eam;
42pub mod gan;
43pub mod idp;
44pub mod license;
45pub mod logs;
46pub mod metrics;
47pub mod projects;
48pub mod query;
49pub mod redundancy;
50pub mod resources;
51pub mod restart;
52pub mod scripts_codec;
53pub mod sessions;
54pub mod status;
55pub mod tags;
56pub mod trial;
57pub mod version;
58pub mod webdev;
59pub mod workspace;
60
61use crate::client::connections::GatewayConnection;
62use crate::client::diagnostics::BundleStatusWire;
63use crate::client::eam::{
64    DeleteOutcome, EamHistoryItem, EamScheduledTask, EamTaskRecord, ModifyOutcome,
65};
66use crate::client::gan::GanStatusWire;
67use crate::client::license::LicenseStatusWire;
68use crate::client::logs::{LogDownload, LogEntry, LogQuery, LoggerInfo};
69use crate::client::metrics::{CurrentGauges, PerformanceCharts, ThreadCounts};
70use crate::client::projects::{
71    ExportMeta, ImportOutcome, ProjectCopy, ProjectCreate, ProjectModify, ProjectRecord,
72    ProjectRenameBody,
73};
74use crate::client::query::ListEnvelope;
75use crate::client::redundancy::RedundancyStatusWire;
76use crate::client::restart::SecurityProperties;
77use crate::client::sessions::{DesignerInfo, PerspectiveSession, VisionClient};
78use crate::client::status::{ModuleInfo, Overview, StatusPing};
79use crate::client::tags::{TagProviderCreate, TagProviderRecord};
80use crate::client::trial::{BannerSet, TrialWire};
81use crate::client::version::GatewayInfo;
82use crate::client::webdev::{RouteBody, RouteProbe};
83use crate::config::{Credential, Profile};
84use crate::error::CoreError;
85
86/// GET path of the gateway-info capability.
87const GATEWAY_INFO_PATH: &str = "/data/api/v1/gateway-info";
88
89/// One capability per method — coarse on purpose. Phase 2 adds status,
90/// modules, metrics, … as methods here; actions never see reqwest types.
91///
92/// (All impl bodies live in the ONE `impl GatewayApi for
93/// ReqwestGatewayApi` block below: Rust rejects a second impl block of
94/// the same trait for the same type, so the per-capability files own the
95/// models + verified path constants and this block owns the delegation.)
96#[async_trait::async_trait]
97pub trait GatewayApi: Send + Sync {
98    /// Fetch `/data/api/v1/gateway-info`.
99    async fn gateway_info(&self) -> Result<GatewayInfo, CoreError>;
100    /// Fetch `/data/api/v1/overview` (authed) — platform + runtime.
101    async fn overview(&self) -> Result<Overview, CoreError>;
102    /// Fetch `/StatusPing` **header-less** (auth=false) — the
103    /// unauthenticated readiness anchor: it must keep answering when
104    /// credentials are broken or absent and mid-restart (02-02).
105    async fn status_ping(&self) -> Result<StatusPing, CoreError>;
106    /// Fetch `/data/api/v1/modules/healthy` (`quarantined = false`) or
107    /// `/modules/quarantined` (`true`) with the standard list params.
108    async fn modules(
109        &self,
110        quarantined: bool,
111        query: &query::ListQuery,
112    ) -> Result<ListEnvelope<ModuleInfo>, CoreError>;
113    /// DELETE `/data/api/v1/modules/uninstall` (Phase 19,
114    /// D-19-03/D-19-04) — uninstalls ONE module by its GATEWAY id.
115    /// **Denial rides HTTP 200**: the gateway always answers 200, so
116    /// the verdict comes from the body's `success`/`failedUninstalls`,
117    /// never the status alone (live-verified: a stock module, a bogus
118    /// id, and a still-mounted module all answer 200 with
119    /// `success:false`). Never batches — exactly one id per call
120    /// (D-19-04 sidesteps the never-observed mixed-batch shape
121    /// entirely). `module_id` is the REGISTRY slug (never sent on the
122    /// wire) so a denial can name both ids without a reverse lookup.
123    async fn uninstall_module(
124        &self,
125        gateway_module_id: &str,
126        module_id: &str,
127    ) -> Result<(), CoreError>;
128    /// Fetch `/data/api/v1/systemPerformance/currentGauges` (authed) —
129    /// cpu in PERCENT (contrast [`Overview::cpu`], a 0–1 fraction).
130    async fn metrics_current(&self) -> Result<CurrentGauges, CoreError>;
131    /// Fetch `/data/api/v1/systemPerformance/charts` (authed) — historic
132    /// cpu/heap/non-heap datapoints (epoch-ms timestamps).
133    async fn metrics_historic(&self) -> Result<PerformanceCharts, CoreError>;
134    /// Fetch `/data/api/v1/systemPerformance/threads` (authed) — thread
135    /// execution counts (running/waiting/timedWaiting/blocked).
136    async fn metrics_threads(&self) -> Result<ThreadCounts, CoreError>;
137    /// Fetch `/data/api/v1/designers` (authed) — active Designer
138    /// sessions (02-03, HLTH-08).
139    async fn designers(
140        &self,
141        query: &query::ListQuery,
142    ) -> Result<ListEnvelope<DesignerInfo>, CoreError>;
143    /// Fetch `/data/perspective/api/v1/sessions/` (authed) — the EXACT
144    /// trailing slash is the contract (Pitfall 8; module-scoped prefix).
145    async fn perspective_sessions(
146        &self,
147        query: &query::ListQuery,
148    ) -> Result<ListEnvelope<PerspectiveSession>, CoreError>;
149    /// Fetch `/data/vision/api/v1/clients` (authed) — active Vision
150    /// clients (designer shape + `tagCount`).
151    async fn vision_clients(
152        &self,
153        query: &query::ListQuery,
154    ) -> Result<ListEnvelope<VisionClient>, CoreError>;
155    /// DELETE `/data/perspective/api/v1/sessions?sessionId=<id>` (+ an
156    /// optional `message` shown to the session's user) — NO trailing
157    /// slash on the DELETE (spec). Audit-logged server-side.
158    async fn terminate_perspective_session(
159        &self,
160        id: &str,
161        message: Option<&str>,
162    ) -> Result<(), CoreError>;
163    /// DELETE `/data/vision/api/v1/client/{id}` — terminate a Vision
164    /// client. Audit-logged server-side.
165    async fn terminate_vision_client(&self, id: &str) -> Result<(), CoreError>;
166    /// DELETE `/data/api/v1/designer/{id}` — prune a Designer session.
167    /// Audit-logged server-side.
168    async fn prune_designer(&self, id: &str) -> Result<(), CoreError>;
169    /// Fetch `/data/api/v1/resources/list/ignition/database-connection`
170    /// (authed) — the web UI's Connections→Databases poll (HLTH-05).
171    /// `healthchecks` is raw passthrough (LOW-confidence populated
172    /// shape, research Open Question 1).
173    async fn database_connections(&self) -> Result<ListEnvelope<GatewayConnection>, CoreError>;
174    /// Fetch `/data/api/v1/resources/list/ignition/opc-connection`
175    /// (authed) — the Connections→OPC poll (HLTH-06), same family.
176    async fn opc_connections(&self) -> Result<ListEnvelope<GatewayConnection>, CoreError>;
177    /// Fetch `/data/api/v1/logs` (authed) with [`LogQuery`] — the tail
178    /// primitive: `startTime` (epoch ms) is the cursor, no server push
179    /// exists (02-04, HLTH-03). The query ALWAYS carries an explicit
180    /// `limit` (Pitfall 9 — the server default is unlimited).
181    async fn logs(&self, filter: &LogQuery) -> Result<ListEnvelope<LogEntry>, CoreError>;
182    /// GET `/data/api/v1/logs/download` (authed, per-request 120 s
183    /// timeout) — a SQLite `.idb` archive, returned byte-for-byte with
184    /// the `Content-Disposition` filename and `Content-Type`. NEVER
185    /// zipped/extracted (Pitfall 7; Don't-Hand-Roll table).
186    async fn logs_download(&self) -> Result<LogDownload, CoreError>;
187    /// Fetch `/data/api/v1/logs/loggers` (authed) — the logger registry
188    /// (HLTH-04; ~1250 loggers on a fresh gateway).
189    async fn loggers(
190        &self,
191        query: &query::ListQuery,
192    ) -> Result<ListEnvelope<LoggerInfo>, CoreError>;
193    /// POST `/data/api/v1/logs/loggers/{loggerName}?level=X` (authed,
194    /// empty body, NO CSRF — verified: token mutations need none).
195    /// Logger names are Java identifiers `[A-Za-z0-9._]` — URL-safe,
196    /// embedded as-is. Audit-logged server-side.
197    async fn set_logger_level(&self, logger: &str, level: &str) -> Result<(), CoreError>;
198    /// POST `/data/api/v1/logs/levelreset` (authed, empty body) — reset
199    /// all custom logger levels to defaults. Audit-logged server-side.
200    async fn reset_logger_levels(&self) -> Result<(), CoreError>;
201    /// POST `/data/api/v1/restart-tasks/restart?confirm=true` (authed,
202    /// empty body, NO CSRF — token mutations need none) — the one big
203    /// red button. The gateway answers 200 with the literal body `true`
204    /// almost immediately; the ~40 s wait is poller-side (02-05's
205    /// `restart --wait` owns it). Audit-logged server-side.
206    async fn restart(&self) -> Result<(), CoreError>;
207    /// POST `/data/api/v1/scan/projects` (authed) — the harmless
208    /// project-rescan write probe (`ign doctor --check-write`; 2xx =
209    /// write permission, 403 = read-only token).
210    async fn scan_projects(&self) -> Result<(), CoreError>;
211    /// GET `/data/api/v1/resources/ignition/security-properties`
212    /// (authed) — the security config singleton; the doctor's
213    /// permissions deep-dive surfaces `readPermissions`/
214    /// `writePermissions` verbatim (passthrough shape).
215    async fn security_properties(&self) -> Result<SecurityProperties, CoreError>;
216    /// GET `/system/webdev/<route>` (authed) reporting the RAW HTTP
217    /// status — the doctor's route-presence probe (404 = absent;
218    /// 200/401/403 = exists). Deliberately NOT classified: presence
219    /// IS the answer; only transport failures are errors.
220    async fn webdev_route_status(&self, route: &str) -> Result<u16, CoreError>;
221    /// POST `/system/webdev/{project}/cli/{route}` (authed + any
222    /// caller headers — scriptExec's secret gate) with the action
223    /// JSON. classify() runs for transport/status errors, BUT the
224    /// 200 BODY is the route envelope `{ok, data|error}` — WebDev
225    /// IGNORES `status`, so denials ride HTTP 200: `ok:false` maps
226    /// `error.code` onto the taxonomy (05-03), `ok:true` returns
227    /// `data`. HTTP 200 alone is NEVER a success verdict.
228    async fn webdev_route_call(
229        &self,
230        project: &str,
231        route: &str,
232        body: &serde_json::Value,
233        extra_headers: &[(&str, &str)],
234    ) -> Result<serde_json::Value, CoreError>;
235    /// POST the route action with a PER-REQUEST timeout override —
236    /// the large-payload escape hatch (the `get_bytes`
237    /// `RequestBuilder::timeout` pattern, 02-04/09-05): the 30 s
238    /// client default would truncate the Phase-11 bulk exportTags
239    /// transfer (research Pitfall 6), so the bulk arms override with
240    /// [`crate::client::tags::TAGS_EXPORT_TIMEOUT`]. Default body =
241    /// the plain call — test doubles and any impl without an
242    /// override path ride the client default (the override only
243    /// lengthens the ceiling; request/response semantics are the
244    /// same classify + envelope parse).
245    async fn webdev_route_call_with_timeout(
246        &self,
247        project: &str,
248        route: &str,
249        body: &serde_json::Value,
250        extra_headers: &[(&str, &str)],
251        _timeout: Duration,
252    ) -> Result<serde_json::Value, CoreError> {
253        self.webdev_route_call(project, route, body, extra_headers)
254            .await
255    }
256    /// POST the route's `{"action":"version"}` handshake and
257    /// discriminate ([`webdev::RouteProbe`]): 200-body-ok →
258    /// `Present{route_version}`, 405 → `Absent` (the live-proven 8.3
259    /// marker — NOT 404), 402 → `Unlicensed`, 401/403 → `AuthGated`,
260    /// 200-body-denial → `Denied{code,message}`. Deliberately NOT
261    /// classified — the status code IS the answer (the
262    /// `webdev_route_status` precedent); only transport failures and
263    /// shapes the enum has no variant for (wizard redirects, 503
264    /// restarts, foreign 404s) are errors.
265    async fn webdev_route_probe(
266        &self,
267        project: &str,
268        route: &str,
269        extra_headers: &[(&str, &str)],
270    ) -> Result<RouteProbe, CoreError>;
271    /// GET `/data/api/v1/projects/list` (authed) — every RUNNABLE
272    /// project with inheritance info from the items themselves
273    /// (PROJ-01; standard list params, `limit=-1` UI convention).
274    async fn projects(
275        &self,
276        query: &query::ListQuery,
277    ) -> Result<ListEnvelope<ProjectRecord>, CoreError>;
278    /// GET `/data/api/v1/projects/find/{name}` (authed, name
279    /// percent-encoded per segment) — one project's full record; 404 →
280    /// `NotFound` via classify (this doubles as 03-02's collision
281    /// pre-check).
282    async fn project_find(&self, name: &str) -> Result<ProjectRecord, CoreError>;
283    /// POST `/data/api/v1/projects` (authed, JSON body) — create. Ok
284    /// classification IS the success contract (create's response body
285    /// is unverified LOW — the restart `literal true` precedent;
286    /// callers that want data re-`find`). Audit-logged server-side.
287    async fn project_create(&self, body: &ProjectCreate) -> Result<(), CoreError>;
288    /// POST `/data/api/v1/projects/copy` (authed, body exactly
289    /// `{"fromName":…,"toName":…}`) — an exact copy of all resources.
290    /// Audit-logged server-side.
291    async fn project_copy(&self, from: &str, to: &str) -> Result<(), CoreError>;
292    /// POST `/data/api/v1/projects/rename/{name}` (authed, body
293    /// `{"name": "<new>"}`) — native rename, NOT copy+delete.
294    /// Audit-logged server-side.
295    async fn project_rename(&self, name: &str, new_name: &str) -> Result<(), CoreError>;
296    /// PUT `/data/api/v1/projects/{name}` (authed, JSON body WITHOUT
297    /// `name`) — modify/reparent (`set --parent` IS the inheritance
298    /// move). Audit-logged server-side.
299    async fn project_modify(&self, name: &str, body: &ProjectModify) -> Result<(), CoreError>;
300    /// DELETE `/data/api/v1/projects/{name}?confirm=true` (authed,
301    /// empty body) — the server's own confirmation guard rides the
302    /// QUERY string (Pitfall 8: BOTH layers, always — the CLI's
303    /// `--yes` and the wire's `confirm=true`). Audit-logged
304    /// server-side.
305    async fn project_delete(&self, name: &str) -> Result<(), CoreError>;
306    /// GET `/data/api/v1/projects/export/{name}` (authed, per-request
307    /// [`projects::PROJECT_EXPORT_TIMEOUT`] = 120 s) — the project ZIP
308    /// STREAMED to `out` chunk-by-chunk via `bytes_stream` (Pitfall 2:
309    /// NO `Vec<u8>` accumulation anywhere), with the disposition
310    /// filename + byte count in the meta. Audit-relevant only as a
311    /// read (exports never mutate).
312    async fn project_export_to_file(&self, name: &str, out: &Path)
313    -> Result<ExportMeta, CoreError>;
314    /// POST `/data/api/v1/projects/import/{name}?overwrite=<bool>`
315    /// (authed, per-request [`projects::PROJECT_IMPORT_TIMEOUT`] =
316    /// 300 s) — the ZIP as the RAW body with `Content-Type:
317    /// application/zip` and a known `Content-Length` (a `Vec<u8>`
318    /// sidesteps the chunked-encoding question entirely — Pitfall 3's
319    /// timeout is handled by the override). Synchronous, no job IDs
320    /// (verified). Audit-logged server-side.
321    async fn project_import(
322        &self,
323        name: &str,
324        zip: Vec<u8>,
325        overwrite: bool,
326    ) -> Result<ImportOutcome, CoreError>;
327    /// GET `/data/api/v1/resources/list/ignition/tag-provider`
328    /// (authed) — the tag-provider resource list: full records
329    /// incl. `config`, `metrics.tagCount`, `healthchecks.status`
330    /// (05-04, TAGS-01 — the NATIVE provider seam; no deployed
331    /// route involved). Standard list params (limit=-1, the UI
332    /// convention).
333    async fn tag_provider_list(
334        &self,
335        query: &query::ListQuery,
336    ) -> Result<ListEnvelope<TagProviderRecord>, CoreError>;
337    /// GET `/data/api/v1/resources/find/ignition/tag-provider/{name}`
338    /// (authed, name percent-encoded per segment) — one provider's
339    /// full record incl. the `signature` the chained delete needs.
340    /// 404 → `NotFound` via classify.
341    async fn tag_provider_find(&self, name: &str) -> Result<TagProviderRecord, CoreError>;
342    /// POST `/data/api/v1/resources/ignition/tag-provider` (authed)
343    /// with a JSON **ARRAY** body of create records — the
344    /// live-proven create shape (05-RESEARCH provider table).
345    /// Audit-logged server-side.
346    async fn tag_provider_create(&self, body: &[TagProviderCreate]) -> Result<(), CoreError>;
347    /// DELETE `/data/api/v1/resources/ignition/tag-provider/{name}/{signature}`
348    /// (authed, both segments percent-encoded) — delete-by-signature;
349    /// the signature comes from find. Audit-logged server-side.
350    async fn tag_provider_delete(&self, name: &str, signature: &str) -> Result<(), CoreError>;
351    /// GET `/data/api/v1/trial` — the trial state, live-verified
352    /// UNAUTHENTICATED on 8.3.3 + 8.3.6 (both trial states): auth
353    /// headers ride ONLY when the client carries a credential (fresh
354    /// rigs have none — the version-command degradation precedent,
355    /// rig-family edition).
356    async fn trial_status_wire(&self) -> Result<TrialWire, CoreError>;
357    /// GET `/data/api/v1/overview/banners` — the trial cross-check
358    /// (severity/expireTime semantics, Pitfall 7). Same conditional
359    /// auth as [`Self::trial_status_wire`].
360    async fn banners(&self) -> Result<BannerSet, CoreError>;
361    /// POST `/data/api/v1/trial` (authed, empty body) — the trial
362    /// RESET, tier 0 of the ladder: a token credential plausibly
363    /// satisfies it without CSRF (token mutations need none — the
364    /// restart/set-logger precedent). The 2xx body IS the fresh
365    /// [`TrialWire`] (live-observed). NOTE (live-discovered state
366    /// gate): the gateway 403s resets on a NON-expired trial — the
367    /// action layer pre-checks expiry.
368    async fn trial_reset_wire(&self) -> Result<TrialWire, CoreError>;
369    /// GET `/data/api/v1/backup?type={roaming|all}` (authed,
370    /// [`backup::BACKUP_TIMEOUT`] = 300 s, `Accept:
371    /// application/octet-stream`) — the portable gwbk STREAMED to
372    /// `out` chunk-by-chunk through the 03-02 `download_to_file`
373    /// pipeline (the ONE streaming body-consumption site — never a
374    /// `Vec<u8>`, Pitfall 2). Byte count + metadata ride out in
375    /// [`ExportMeta`] (04-04, RIG-04; 07-02 param-ized the type —
376    /// `Roaming` stays the caller default).
377    async fn backup_download(
378        &self,
379        out: &Path,
380        backup_type: backup::BackupType,
381    ) -> Result<ExportMeta, CoreError>;
382    /// POST `/data/api/v1/backup` (authed, [`backup::BACKUP_TIMEOUT`]
383    /// = 300 s) — the RESTORE: the gwbk bytes as a RAW
384    /// `application/octet-stream` body (NOT multipart — the postman
385    /// collection's exact shape) with the four scope params EXPLICIT
386    /// on the query string. Synchronous AND followed by a gateway
387    /// restart (Pitfall 6): the 2xx means the restore was ACCEPTED —
388    /// the actions layer owns the post-restore RUNNING wait. The
389    /// upload direction buffers by design (the import precedent).
390    async fn backup_restore(&self, gwbk: &Path) -> Result<(), CoreError>;
391    /// GET `/data/eam/api/v1/eam-tasks/history` (authed) — task run
392    /// history, the standard `{items, metadata}` envelope. `limit`
393    /// defaults to [`eam::EAM_HISTORY_DEFAULT_LIMIT`] (200 — EAM
394    /// history grows unboundedly; an explicit limit ALWAYS rides the
395    /// wire, the logs discipline). A stock (non-controller) gateway
396    /// 403s → [`CoreError::EamNotController`] via classify
397    /// (path-scoped message classification — never a misleading
398    /// `auth_rejected`).
399    async fn eam_task_history(
400        &self,
401        limit: Option<u32>,
402        search: Option<&str>,
403    ) -> Result<ListEnvelope<EamHistoryItem>, CoreError>;
404    /// GET `/data/api/v1/resources/list/com.inductiveautomation.eam/
405    /// eam-tasks` (authed) — task DEFINITIONS through the standard
406    /// config-resource family (the tag-provider pattern; available
407    /// on stock gateways — no controller needed for definitions).
408    async fn eam_task_definitions(&self) -> Result<ListEnvelope<EamTaskRecord>, CoreError>;
409    /// GET `/data/api/v1/resources/find/com.inductiveautomation.eam/
410    /// eam-tasks/{name}` (authed) — one definition's full record
411    /// incl. the `scheduledTaskState` healthcheck
412    /// (`currentState`/`nextScheduled`/`owner` under `details`) and
413    /// the mutation `signature`. 404 → `NotFound` via classify.
414    async fn eam_task_find(&self, name: &str) -> Result<EamTaskRecord, CoreError>;
415    /// POST `/data/api/v1/resources/com.inductiveautomation.eam/
416    /// eam-tasks` (authed) with a JSON **ARRAY** body of one
417    /// definition record — the config-resource create shape (the
418    /// tag-provider precedent). Ok classification IS the success
419    /// contract (create's response body is unverified — the
420    /// project-create precedent; callers that want data re-find).
421    /// Audit-logged server-side.
422    async fn eam_task_create(&self, definition: &serde_json::Value) -> Result<(), CoreError>;
423    /// POST `/data/eam/api/v1/eam-tasks/force/{owner}/{name}` (authed,
424    /// empty body) — dispatch a task NOW. Live-proven success shape:
425    /// **204** (any 2xx is done — the route-status style; execution
426    /// OUTCOMES surface later in history as data, never on this
427    /// response). Runtime seam: the controller gate classifies.
428    async fn eam_task_force(&self, owner: &str, name: &str) -> Result<(), CoreError>;
429    /// POST `/data/eam/api/v1/eam-tasks/suspend/{name}` (authed,
430    /// empty body) — suspend the task's scheduler trigger.
431    /// Live-proven success shape: **204**; the flag PERSISTS into
432    /// `config.profile.isSuspended` (10-LIVE-CAPTURES §1c/Decision 1).
433    /// Requires a live scheduler trigger — an OnDemand/untriggered
434    /// (or unknown-named, §7) task answers 500 Jetty HTML, which
435    /// classifies as `Internal` with the page's own message (no
436    /// honest 404 exists on this seam).
437    async fn eam_task_suspend(&self, name: &str) -> Result<(), CoreError>;
438    /// POST `/data/eam/api/v1/eam-tasks/resume/{name}` (authed,
439    /// empty body) — the inverse of [`Self::eam_task_suspend`]:
440    /// **204** success, `isSuspended` back to `false` (§1d). Unknown
441    /// names answer 500 HTML naming the task (§7).
442    async fn eam_task_resume(&self, name: &str) -> Result<(), CoreError>;
443    /// POST `/data/eam/api/v1/eam-tasks/cancel/{name}` (authed,
444    /// empty body) — cancel a PENDING execution. Always **204** on
445    /// the captured shapes: nothing-pending AND unknown-name are
446    /// silent successes (§7) — cancel is never a name-validation
447    /// tool (the actions layer owns find-before-write).
448    async fn eam_task_cancel(&self, name: &str) -> Result<(), CoreError>;
449    /// GET `/data/eam/api/v1/eam-tasks/scheduled/{running}` (authed)
450    /// — the pending-execution read; `{running}` is the LITERAL word
451    /// `true`/`false` (§2). Answers the standard
452    /// `{items, metadata}` envelope; this method unwraps it to the
453    /// items ([`EamScheduledTask`] — 13 capture-locked keys,
454    /// `taskState` String vocabulary). A stock gateway 403s →
455    /// [`CoreError::EamNotController`] via the same path-scoped arm
456    /// as every runtime seam call.
457    async fn eam_tasks_scheduled(&self, running: bool) -> Result<Vec<EamScheduledTask>, CoreError>;
458    /// PUT `/data/api/v1/resources/com.inductiveautomation.eam/
459    /// eam-tasks` (authed) with a single-element JSON **ARRAY**
460    /// carrying the FULL find record (settings included — omitting
461    /// `config.settings` ⇒ 422, the create trap) and the ORIGINAL
462    /// `signature`. The 200 body is the captured
463    /// [`ModifyOutcome`] `{success, changes[], problem}`
464    /// (§6a); `None` = a 2xx with an empty body (lenient). Rename via
465    /// PUT is NOT supported (§5: changed name + original signature ⇒
466    /// 404 — the actions layer must compose create-new + delete-old).
467    async fn eam_task_modify(
468        &self,
469        definition: &serde_json::Value,
470    ) -> Result<Option<ModifyOutcome>, CoreError>;
471    /// DELETE `/data/api/v1/resources/com.inductiveautomation.eam/
472    /// eam-tasks/{name}/{signature}` (authed, both segments
473    /// percent-encoded) with `?collection=core` ALWAYS (the
474    /// collection VALUE — `collection=eam-tasks` 404s, §3c) and
475    /// `confirm=true` only when the caller opts in: a lone-resource
476    /// delete SUCCEEDS without it (§3b) and the confirm-demand shape
477    /// is UNOBSERVED (§3d) — never hard-coded. The 200 body is the
478    /// captured [`DeleteOutcome`] (adds `references`). A signature
479    /// mismatch answers HTTP 500 + `problem` — see the
480    /// [`eam`] module docs for the recorded FINDING (not classified
481    /// here; slug decision belongs to 10-03/10-04).
482    async fn eam_task_delete(
483        &self,
484        name: &str,
485        signature: &str,
486        confirm: bool,
487    ) -> Result<DeleteOutcome, CoreError>;
488    /// The raw passthrough (09-03, EXT-01): send `call` with its
489    /// arbitrary method, caller headers, query pairs, and optional raw
490    /// body (ANY method — GET/DELETE bodies allowed, curl parity).
491    /// Auth rides [`ReqwestGatewayApi::apply_auth`] (the ONE
492    /// `Secret::expose` site — user auth-pattern headers are refused
493    /// by [`apicall::refuse_auth_headers`] at the CLI/action layer,
494    /// never stripped); classification rides the api-call pipeline
495    /// ([`ReqwestGatewayApi::send_and_classify_for_api`]), so an
496    /// unclassified gateway 4xx is `GatewayClientError` (exit 2,
497    /// verbatim capped body). The 2xx body returns VERBATIM
498    /// ([`apicall::ApiCallData`] — `RawValue` passthrough: no field
499    /// dropped, no value coerced, key order preserved); a non-JSON
500    /// 2xx body is the honest internal-class refusal. The usage
501    /// guards live in [`apicall`] and the action layer — BOTH run
502    /// them, so in-process callers cannot skip the checks.
503    async fn api_call(
504        &self,
505        call: &apicall::ApiCallRequest,
506    ) -> Result<apicall::ApiCallData, CoreError>;
507    /// GET `/data/api/v1/licenses` (authed) — the license inventory
508    /// (09-04). The wire model is PARTIAL-CURATED: the morning-check
509    /// skeleton typed, array elements + `details` passthrough (the
510    /// fresh-rig captures answered empty arrays — element shapes are
511    /// not capture-proven).
512    async fn license_status(&self) -> Result<LicenseStatusWire, CoreError>;
513    /// GET `/data/api/v1/redundancy` (authed) — the flat 11-field
514    /// redundancy status (09-04). Units are capture-locked at the
515    /// model (`uptime` ms-since-start wall-clock-proven; the
516    /// `lastSyncTimestamp` `-1` sentinel normalized via
517    /// [`redundancy::RedundancyStatusWire::last_sync_epoch_ms`]).
518    async fn redundancy_status(&self) -> Result<RedundancyStatusWire, CoreError>;
519    /// GET `/data/api/v1/overview/gan` (authed) — the 5-field GAN
520    /// summary (09-04); a non-GAN gateway's zero-connection body IS
521    /// the canonical capture.
522    async fn gan_status(&self) -> Result<GanStatusWire, CoreError>;
523    /// POST `/data/api/v1/diagnostics/bundle/generate` (authed, no
524    /// body) — start bundle generation (09-05). The 200 body IS the
525    /// fresh [`BundleStatusWire`] (live capture:
526    /// `{"state":"Generating"}`). Audit-logged server-side.
527    async fn bundle_generate(&self) -> Result<BundleStatusWire, CoreError>;
528    /// GET `/data/api/v1/diagnostics/bundle/status` (authed) — the
529    /// status poll: captured state vocabulary + `fileSize` (bytes,
530    /// absent while generating — 09-LIVE-CAPTURES §5).
531    async fn bundle_status(&self) -> Result<BundleStatusWire, CoreError>;
532    /// GET `/data/api/v1/diagnostics/bundle/download` (authed,
533    /// per-request [`diagnostics::BUNDLE_DOWNLOAD_TIMEOUT`] = 300 s —
534    /// Pitfall 8: the 30 s client default would truncate MB-sized
535    /// bundles) — the ZIP STREAMED to `out` chunk-by-chunk through the
536    /// `download_to_file` pipeline (classify-first; NO `Vec<u8>`
537    /// anywhere). The `Content-Disposition` filename + `Content-Type`
538    /// ride the pipeline's [`ExportMeta`].
539    async fn bundle_download(&self, out: &Path) -> Result<ExportMeta, CoreError>;
540}
541
542/// Production [`GatewayApi`] over reqwest.
543pub struct ReqwestGatewayApi {
544    base: url::Url,
545    credential: Option<Credential>,
546    client: reqwest::Client,
547}
548
549impl ReqwestGatewayApi {
550    /// Build from a resolved profile (post env-overlay — the dispatch site
551    /// owns that precedence) and an optional credential (`None` = proceed
552    /// header-less; the gateway's answer is then classified — 401 under
553    /// 8.3 default security).
554    ///
555    /// Timeouts: 10s connect / 30s overall (per-class refinements land in
556    /// Phase 2). `ssl_verify = false` accepts invalid certs — dev-rig
557    /// only, per-profile, never global.
558    pub fn new(profile: &Profile, credential: Option<Credential>) -> Result<Self, CoreError> {
559        let client = build_client(profile.ssl_verify)?;
560        Ok(Self {
561            base: profile.url.clone(),
562            credential,
563            client,
564        })
565    }
566
567    /// Test constructor: base URL + credential, no profile needed.
568    pub fn for_tests(base_url: &str, credential: Option<Credential>) -> Self {
569        Self {
570            base: url::Url::parse(base_url).expect("test base URL parses"),
571            credential,
572            client: build_client(true).expect("test client builds"),
573        }
574    }
575
576    /// The full request URL for `path` (bases are normalized to a trailing
577    /// slash; an absolute path replaces from root).
578    fn url_for(&self, path: &str) -> url::Url {
579        self.base.join(path).expect("base joins an absolute path")
580    }
581
582    /// The auth-header rule in ONE place: token XOR basic XOR neither — a
583    /// match, not if/if-else chains. [`Secret::expose`] is called at
584    /// exactly this site (the redaction boundary MOVED here in 02-01, not
585    /// duplicated).
586    ///
587    /// Basic carries a loud demotion warning: it cannot authenticate 8.3
588    /// `/data` routes (verified: valid commissioned credentials → 401) —
589    /// warn once per call, never silently retry (02-RESEARCH Auth §2).
590    fn apply_auth(&self, request: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
591        let mut request = request;
592        match &self.credential {
593            Some(Credential::Token(token)) => {
594                request = request.header("X-Ignition-API-Token", token.expose());
595            }
596            Some(Credential::Basic(user, password)) => {
597                tracing::warn!(
598                    "Basic auth does not authenticate Ignition 8.3 /data routes \
599                     (verified: valid credentials → 401); use an API token"
600                );
601                request = request.basic_auth(user.expose(), Some(password.expose()));
602            }
603            None => {}
604        }
605        request
606    }
607
608    /// GET `path` (with pre-built query `pairs` when given) → classify →
609    /// deserialize into `T`. `auth = false` fetches header-less (the
610    /// `/StatusPing` readiness probe, 02-02 — it must work with broken
611    /// credentials). Callers build pairs via `to_query_pairs()` so the
612    /// param-name mapping stays in the capability files.
613    async fn get_json<T: serde::de::DeserializeOwned>(
614        &self,
615        path: &str,
616        pairs: Option<&[(String, String)]>,
617        auth: bool,
618    ) -> Result<T, CoreError> {
619        let url = self.url_for(path);
620        let mut request = self.client.get(url.clone());
621        if let Some(pairs) = pairs {
622            request = request.query(&pairs);
623        }
624        if auth {
625            request = self.apply_auth(request);
626        }
627        let response = self.send_and_classify(request, &url).await?;
628        response.json::<T>().await.map_err(|err| {
629            CoreError::Internal(format!(
630                "response from {url} did not match the expected shape: {err}"
631            ))
632        })
633    }
634
635    /// GET `path` → classify → read the response as BYTES plus the
636    /// `Content-Disposition` filename and `Content-Type` — the
637    /// archive-download pipeline (02-04). `timeout` overrides the 30 s
638    /// client default PER REQUEST (a large `.idb` archive must not be
639    /// truncated) — `RequestBuilder::timeout`, not a second client.
640    async fn get_bytes(&self, path: &str, timeout: Duration) -> Result<LogDownload, CoreError> {
641        let url = self.url_for(path);
642        let request = self.client.get(url.clone()).timeout(timeout);
643        let request = self.apply_auth(request);
644        let response = self.send_and_classify(request, &url).await?;
645        let filename = response
646            .headers()
647            .get(reqwest::header::CONTENT_DISPOSITION)
648            .and_then(|value| value.to_str().ok())
649            .and_then(logs::filename_from_content_disposition);
650        let content_type = response
651            .headers()
652            .get(reqwest::header::CONTENT_TYPE)
653            .and_then(|value| value.to_str().ok())
654            .map(str::to_string);
655        let bytes = response.bytes().await.map_err(|err| CoreError::Network {
656            url: url.to_string(),
657            source: Some(err),
658            observation: None,
659        })?;
660        Ok(LogDownload {
661            bytes: bytes.to_vec(),
662            filename,
663            content_type,
664        })
665    }
666
667    /// GET `path` → classify → STREAM the body to `out` chunk-by-chunk
668    /// — the file-download pipeline (03-02). The response body is
669    /// consumed HERE, at a pipeline site, classify-first like every
670    /// other: an error answer must classify (never stream), and on
671    /// success each `bytes_stream()` chunk goes straight through
672    /// `AsyncWriteExt::write_all` into a `tokio::fs::File` — NO
673    /// `Vec<u8>` accumulation anywhere (Pitfall 2: a multi-hundred-MB
674    /// export ZIP must not buffer in memory). The response metadata
675    /// (`Content-Disposition` filename, `Content-Type`) and the
676    /// chunk-counted byte total ride out in [`ExportMeta`]. Requires
677    /// the workspace `reqwest` `stream` + `tokio` `fs` features (the
678    /// research-flagged dep gap this plan closed).
679    ///
680    /// `accept` adds an OPTIONAL `Accept` header for the callers whose
681    /// server contract names one (04-04's gwbk download sends
682    /// `application/octet-stream`; the 03-02 export sends none) — a
683    /// minimal parameterization that keeps THIS the one streaming
684    /// site instead of forking a second copy of the chunk loop.
685    async fn download_to_file(
686        &self,
687        path: &str,
688        out: &Path,
689        timeout: Duration,
690        accept: Option<&str>,
691    ) -> Result<ExportMeta, CoreError> {
692        use futures_util::StreamExt;
693        use tokio::io::AsyncWriteExt;
694
695        let url = self.url_for(path);
696        let mut request = self.client.get(url.clone()).timeout(timeout);
697        if let Some(accept) = accept {
698            request = request.header(reqwest::header::ACCEPT, accept);
699        }
700        let request = self.apply_auth(request);
701        let response = self.send_and_classify(request, &url).await?;
702        let filename = response
703            .headers()
704            .get(reqwest::header::CONTENT_DISPOSITION)
705            .and_then(|value| value.to_str().ok())
706            .and_then(logs::filename_from_content_disposition);
707        let content_type = response
708            .headers()
709            .get(reqwest::header::CONTENT_TYPE)
710            .and_then(|value| value.to_str().ok())
711            .map(str::to_string);
712
713        let mut file = tokio::fs::File::create(out).await.map_err(|err| {
714            CoreError::Internal(format!("cannot create {}: {err}", out.display()))
715        })?;
716        let mut stream = response.bytes_stream();
717        let mut bytes: u64 = 0;
718        while let Some(chunk) = stream.next().await {
719            let chunk = chunk.map_err(|err| CoreError::Network {
720                url: url.to_string(),
721                source: Some(err),
722                observation: None,
723            })?;
724            file.write_all(&chunk).await.map_err(|err| {
725                CoreError::Internal(format!("cannot write {}: {err}", out.display()))
726            })?;
727            bytes += chunk.len() as u64;
728        }
729        file.flush()
730            .await
731            .map_err(|err| CoreError::Internal(format!("cannot flush {}: {err}", out.display())))?;
732        Ok(ExportMeta {
733            filename,
734            bytes,
735            content_type,
736        })
737    }
738
739    /// POST `path` with `pairs` as QUERY params and an empty body →
740    /// classify → hand back the response (callers read `true`/JSON as
741    /// their capability needs). Production callers since 02-04:
742    /// `set_logger_level`, `reset_logger_levels` (and 02-05's restart
743    /// with `confirm=true`). Token-auth POSTs need NO CSRF (verified
744    /// 02-RESEARCH §Auth Model).
745    async fn post_empty(
746        &self,
747        path: &str,
748        pairs: &[(&str, String)],
749        auth: bool,
750    ) -> Result<reqwest::Response, CoreError> {
751        let url = self.url_for(path);
752        let mut request = self.client.post(url.clone()).query(pairs);
753        if auth {
754            request = self.apply_auth(request);
755        }
756        self.send_and_classify(request, &url).await
757    }
758
759    /// DELETE `path` with `pairs` as QUERY params (empty body) →
760    /// classify → `Ok(())` on any classified success. Token-auth DELETEs
761    /// need NO CSRF (verified 02-RESEARCH §Auth Model: CSRF is only for
762    /// cookie/session auth); the classified bodies (`{terminated: N}`,
763    /// `{message: …}`) are advisory — Ok classification IS the success
764    /// contract.
765    async fn delete_with_query(
766        &self,
767        path: &str,
768        pairs: &[(&str, String)],
769    ) -> Result<(), CoreError> {
770        let url = self.url_for(path);
771        let mut request = self.client.delete(url.clone()).query(pairs);
772        request = self.apply_auth(request);
773        self.send_and_classify(request, &url).await.map(|_| ())
774    }
775
776    /// DELETE `path` with a JSON body → classify → hand back the
777    /// response — [`Self::delete_with_query`] cannot carry a body (it
778    /// sends an EMPTY one). Mirrors [`Self::post_json`] exactly (Phase
779    /// 19: the module-uninstall DELETE is the first DELETE-with-body
780    /// caller). Token-auth DELETEs need NO CSRF (verified 02-RESEARCH
781    /// §Auth Model).
782    async fn delete_json<T: serde::Serialize + ?Sized>(
783        &self,
784        path: &str,
785        body: &T,
786    ) -> Result<reqwest::Response, CoreError> {
787        let url = self.url_for(path);
788        let request = self.apply_auth(self.client.delete(url.clone()).json(body));
789        self.send_and_classify(request, &url).await
790    }
791
792    /// POST `path` with a JSON body → classify → hand back the response
793    /// (callers read the body as their capability needs; the project
794    /// mutations treat Ok classification AS the success contract —
795    /// those bodies are unverified LOW, the restart `literal true`
796    /// precedent). Token-auth POSTs need NO CSRF (verified
797    /// 02-RESEARCH §Auth Model). One of the two body-carrying pipeline
798    /// helpers (03-01); serde serializes struct fields in declaration order, so
799    /// recorded bodies are deterministic for the wiremock pins.
800    async fn post_json<T: serde::Serialize + ?Sized>(
801        &self,
802        path: &str,
803        body: &T,
804    ) -> Result<reqwest::Response, CoreError> {
805        let url = self.url_for(path);
806        let request = self.apply_auth(self.client.post(url.clone()).json(body));
807        self.send_and_classify(request, &url).await
808    }
809
810    /// POST the action JSON to a webdev route with caller headers +
811    /// auth applied, returning `(full URL, response)` — the shared
812    /// head of the two webdev seam methods (05-03). NO classify here:
813    /// `webdev_route_probe` reads the raw status (the code IS the
814    /// answer); `webdev_route_call` classifies downstream. Transport
815    /// failures map to `Network` like every pipeline.
816    async fn webdev_post_raw(
817        &self,
818        project: &str,
819        route: &str,
820        body: &serde_json::Value,
821        extra_headers: &[(&str, &str)],
822        timeout: Option<Duration>,
823    ) -> Result<(String, reqwest::Response), CoreError> {
824        let path = webdev::route_url(project, route);
825        let url = self.url_for(&path);
826        let mut request = self.client.post(url.clone()).json(body);
827        if let Some(t) = timeout {
828            // Per-request override WITHOUT a second client — the
829            // RequestBuilder::timeout pattern (02-04/09-05).
830            request = request.timeout(t);
831        }
832        for (name, value) in extra_headers {
833            request = request.header(*name, *value);
834        }
835        let request = self.apply_auth(request);
836        let response = request.send().await.map_err(|err| CoreError::Network {
837            url: url.to_string(),
838            source: Some(err),
839            observation: None,
840        })?;
841        Ok((url.to_string(), response))
842    }
843
844    /// POST the JSON body to a TESTING-bundle route
845    /// (`/system/webdev/{project}/testing/{route}`) RAW — the
846    /// `webdev_post_raw` twin for the sibling URL space (QUICK-p0g).
847    ///
848    /// NO classify, deliberately (the `webdev_route_probe`
849    /// precedent): the testing bundle's presence discrimination
850    /// needs the raw status, because [`classify`] collapses
851    /// 405/500/501 into `Internal` (exit 1) and would make an
852    /// undeployed bundle indistinguishable from a CLI bug. Transport
853    /// failures map to `Network` like every pipeline.
854    ///
855    /// The classified siblings ([`webdev::testing_discover`],
856    /// [`webdev::testing_run`]) are untouched — `ign adopt --testing`
857    /// keeps its exact behavior.
858    pub(crate) async fn testing_post_raw(
859        &self,
860        project: &str,
861        route: &str,
862        body: &serde_json::Value,
863    ) -> Result<(String, reqwest::Response), CoreError> {
864        let path = webdev::testing_route_url(project, route);
865        let url = self.url_for(&path);
866        let request = self.apply_auth(self.client.post(url.clone()).json(body));
867        let response = request.send().await.map_err(|err| CoreError::Network {
868            url: url.to_string(),
869            source: Some(err),
870            observation: None,
871        })?;
872        Ok((url.to_string(), response))
873    }
874
875    /// PUT `path` with a JSON body → classify → `Ok(())` (modify/
876    /// reparent; resource puts in 03-03). Token-auth PUTs need NO
877    /// CSRF. The classify-first rule holds: nothing consumes a body
878    /// that skipped classify.
879    async fn put_json<T: serde::Serialize + ?Sized>(
880        &self,
881        path: &str,
882        body: &T,
883    ) -> Result<(), CoreError> {
884        let url = self.url_for(path);
885        let request = self.apply_auth(self.client.put(url.clone()).json(body));
886        self.send_and_classify(request, &url).await.map(|_| ())
887    }
888
889    /// Send + transport-error mapping + [`classify`] — the shared tail of
890    /// every pipeline helper. Transport failures (connect/timeout/TLS) →
891    /// `Network` (exit 4); everything the gateway ANSWERED goes through
892    /// the classifier. Curated traffic: `api_call = false` — the
893    /// catch-all cannot fire without the parameter (09-01 Pitfall 1).
894    async fn send_and_classify(
895        &self,
896        request: reqwest::RequestBuilder,
897        url: &url::Url,
898    ) -> Result<reqwest::Response, CoreError> {
899        let response = request.send().await.map_err(|err| CoreError::Network {
900            url: url.to_string(),
901            source: Some(err),
902            observation: None,
903        })?;
904        classify::classify(response, url.as_ref(), false).await
905    }
906
907    /// The api-call-scoped pipeline entry (09-01): identical
908    /// transport-error → `Network` mapping, then [`classify`] with
909    /// `api_call = true` so an unclassified gateway 4xx maps to
910    /// `GatewayClientError` (exit 2, verbatim capped body) instead of
911    /// `Internal`. Public because `ign api call`'s action layer (09-03)
912    /// is the production consumer and the contract tests
913    /// (tests/api_classify_contract.rs) pin the full exit partition
914    /// through it — nothing in the curated pipeline switches to it.
915    pub async fn send_and_classify_for_api(
916        &self,
917        request: reqwest::RequestBuilder,
918        url: &url::Url,
919    ) -> Result<reqwest::Response, CoreError> {
920        let response = request.send().await.map_err(|err| CoreError::Network {
921            url: url.to_string(),
922            source: Some(err),
923            observation: None,
924        })?;
925        classify::classify(response, url.as_ref(), true).await
926    }
927}
928
929/// Install the process-wide TLS crypto provider (ring). reqwest is built
930/// with `rustls-no-provider` (workspace Cargo.toml) so the provider is the
931/// app's responsibility — this is the single install point for the binary,
932/// and it is `pub` so the e2e tests' raw `reqwest::Client` constructions
933/// can install it too. Safe to call any number of times.
934pub fn install_crypto_provider() {
935    // ring is the process-wide TLS provider (workspace Cargo.toml: reqwest
936    // rides rustls-no-provider). install_default() errors on a second call —
937    // the Once makes every later construction a no-op instead.
938    static ONCE: std::sync::Once = std::sync::Once::new();
939    ONCE.call_once(|| {
940        let _ = rustls::crypto::ring::default_provider().install_default();
941    });
942}
943
944fn build_client(ssl_verify: bool) -> Result<reqwest::Client, CoreError> {
945    install_crypto_provider();
946    let mut builder = reqwest::Client::builder()
947        // Never follow redirects: an uncommissioned gateway 302s everything
948        // to /welcome and the follow would render the wizard HTML as a 200
949        // (02-RESEARCH Pitfall 6). classify() maps the 3xx instead.
950        .redirect(reqwest::redirect::Policy::none())
951        .connect_timeout(Duration::from_secs(10))
952        .timeout(Duration::from_secs(30));
953    if !ssl_verify {
954        builder = builder.danger_accept_invalid_certs(true);
955    }
956    builder
957        .build()
958        .map_err(|err| CoreError::Internal(format!("cannot build HTTP client: {err}")))
959}
960
961#[async_trait::async_trait]
962impl GatewayApi for ReqwestGatewayApi {
963    async fn gateway_info(&self) -> Result<GatewayInfo, CoreError> {
964        let mut info: GatewayInfo = self.get_json(GATEWAY_INFO_PATH, None, true).await?;
965        info.endpoint = Some(self.url_for(GATEWAY_INFO_PATH).to_string());
966        Ok(info)
967    }
968
969    async fn overview(&self) -> Result<Overview, CoreError> {
970        self.get_json(status::OVERVIEW_PATH, None, true).await
971    }
972
973    async fn status_ping(&self) -> Result<StatusPing, CoreError> {
974        // auth = false — the whole point: the readiness anchor must not
975        // depend on credentials (pinned by the wiremock header-absence
976        // proof in tests/status_contract.rs).
977        self.get_json(status::STATUS_PING_PATH, None, false).await
978    }
979
980    async fn modules(
981        &self,
982        quarantined: bool,
983        query: &query::ListQuery,
984    ) -> Result<ListEnvelope<ModuleInfo>, CoreError> {
985        let path = if quarantined {
986            status::MODULES_QUARANTINED_PATH
987        } else {
988            status::MODULES_HEALTHY_PATH
989        };
990        self.get_json(path, Some(&query.to_query_pairs()), true)
991            .await
992    }
993
994    async fn uninstall_module(
995        &self,
996        gateway_module_id: &str,
997        module_id: &str,
998    ) -> Result<(), CoreError> {
999        let url = self.url_for(status::MODULES_UNINSTALL_PATH);
1000        let body = status::ModuleUninstallRequest {
1001            uninstall: vec![gateway_module_id.to_string()],
1002        };
1003        let response = self
1004            .delete_json(status::MODULES_UNINSTALL_PATH, &body)
1005            .await?;
1006        // Denial-rides-200 (D-19-03, live-verified): a stock module, a
1007        // bogus id, and a still-mounted module all answered 200 with
1008        // success:false. An unparseable body, a missing `success`, or
1009        // an absent `failedUninstalls` must NEVER degrade into a
1010        // reported success — every one of those shapes falls through
1011        // to the denial below, never to `Ok`.
1012        let text = response.text().await.unwrap_or_default();
1013        let denial = |failed: Vec<String>| CoreError::ModuleUninstallDenied {
1014            module_id: module_id.to_string(),
1015            gateway_module_id: gateway_module_id.to_string(),
1016            failed,
1017            endpoint: Some(url.to_string()),
1018        };
1019        let Ok(parsed) = serde_json::from_str::<status::ModuleUninstallResponse>(text.trim())
1020        else {
1021            return Err(denial(vec![gateway_module_id.to_string()]));
1022        };
1023        if parsed.success {
1024            return Ok(());
1025        }
1026        let failed = parsed
1027            .failed_uninstalls
1028            .map(|detail| detail.uninstall)
1029            .filter(|ids| !ids.is_empty())
1030            .unwrap_or_else(|| vec![gateway_module_id.to_string()]);
1031        Err(denial(failed))
1032    }
1033
1034    async fn metrics_current(&self) -> Result<CurrentGauges, CoreError> {
1035        self.get_json(metrics::CURRENT_GAUGES_PATH, None, true)
1036            .await
1037    }
1038
1039    async fn metrics_historic(&self) -> Result<PerformanceCharts, CoreError> {
1040        self.get_json(metrics::CHARTS_PATH, None, true).await
1041    }
1042
1043    async fn metrics_threads(&self) -> Result<ThreadCounts, CoreError> {
1044        self.get_json(metrics::THREADS_PATH, None, true).await
1045    }
1046
1047    async fn designers(
1048        &self,
1049        query: &query::ListQuery,
1050    ) -> Result<ListEnvelope<DesignerInfo>, CoreError> {
1051        self.get_json(
1052            sessions::DESIGNERS_PATH,
1053            Some(&query.to_query_pairs()),
1054            true,
1055        )
1056        .await
1057    }
1058
1059    async fn perspective_sessions(
1060        &self,
1061        query: &query::ListQuery,
1062    ) -> Result<ListEnvelope<PerspectiveSession>, CoreError> {
1063        // The trailing slash is PART OF THE PATH (Pitfall 8) — url_for's
1064        // join preserves it; the exact-path wiremock matcher in
1065        // tests/sessions_contract.rs pins it.
1066        self.get_json(
1067            sessions::PERSPECTIVE_SESSIONS_LIST_PATH,
1068            Some(&query.to_query_pairs()),
1069            true,
1070        )
1071        .await
1072    }
1073
1074    async fn vision_clients(
1075        &self,
1076        query: &query::ListQuery,
1077    ) -> Result<ListEnvelope<VisionClient>, CoreError> {
1078        self.get_json(
1079            sessions::VISION_CLIENTS_PATH,
1080            Some(&query.to_query_pairs()),
1081            true,
1082        )
1083        .await
1084    }
1085
1086    async fn terminate_perspective_session(
1087        &self,
1088        id: &str,
1089        message: Option<&str>,
1090    ) -> Result<(), CoreError> {
1091        // sessionId is a QUERY param on the spec's DELETE route — never
1092        // a body (recorded-request proof in tests/sessions_contract.rs).
1093        let mut pairs = vec![("sessionId", id.to_string())];
1094        if let Some(message) = message {
1095            pairs.push(("message", message.to_string()));
1096        }
1097        self.delete_with_query(sessions::PERSPECTIVE_SESSIONS_TERMINATE_PATH, &pairs)
1098            .await
1099    }
1100
1101    async fn terminate_vision_client(&self, id: &str) -> Result<(), CoreError> {
1102        self.delete_with_query(&sessions::vision_client_terminate_path(id), &[])
1103            .await
1104    }
1105
1106    async fn prune_designer(&self, id: &str) -> Result<(), CoreError> {
1107        self.delete_with_query(&sessions::designer_prune_path(id), &[])
1108            .await
1109    }
1110
1111    async fn database_connections(&self) -> Result<ListEnvelope<GatewayConnection>, CoreError> {
1112        // The UI polls the resource list with limit=-1 — same convention
1113        // as every other list capability.
1114        self.get_json(
1115            connections::DATABASE_CONNECTIONS_PATH,
1116            Some(&query::ListQuery::default().to_query_pairs()),
1117            true,
1118        )
1119        .await
1120    }
1121
1122    async fn opc_connections(&self) -> Result<ListEnvelope<GatewayConnection>, CoreError> {
1123        self.get_json(
1124            connections::OPC_CONNECTIONS_PATH,
1125            Some(&query::ListQuery::default().to_query_pairs()),
1126            true,
1127        )
1128        .await
1129    }
1130
1131    async fn logs(&self, filter: &LogQuery) -> Result<ListEnvelope<LogEntry>, CoreError> {
1132        // Explicit limit ALWAYS rides the wire (Pitfall 9) — enforced by
1133        // LogQuery::to_query_pairs, pinned by the contract test.
1134        self.get_json(logs::LOGS_PATH, Some(&filter.to_query_pairs()), true)
1135            .await
1136    }
1137
1138    async fn logs_download(&self) -> Result<LogDownload, CoreError> {
1139        // Per-request timeout override: the 30 s client default would
1140        // truncate large archives (per-class timeout WITHOUT a second
1141        // client — RequestBuilder::timeout, 02-RESEARCH §Architecture).
1142        self.get_bytes(logs::LOGS_DOWNLOAD_PATH, Duration::from_secs(120))
1143            .await
1144    }
1145
1146    async fn loggers(
1147        &self,
1148        query: &query::ListQuery,
1149    ) -> Result<ListEnvelope<LoggerInfo>, CoreError> {
1150        self.get_json(logs::LOGGERS_PATH, Some(&query.to_query_pairs()), true)
1151            .await
1152    }
1153
1154    async fn set_logger_level(&self, logger: &str, level: &str) -> Result<(), CoreError> {
1155        // `level` rides the QUERY string against an EMPTY body (verified
1156        // live: 200 + the level flips; recorded-request proof in
1157        // tests/logs_contract.rs).
1158        self.post_empty(
1159            &logs::logger_set_path(logger),
1160            &[("level", level.to_string())],
1161            true,
1162        )
1163        .await
1164        .map(|_| ())
1165    }
1166
1167    async fn reset_logger_levels(&self) -> Result<(), CoreError> {
1168        self.post_empty(logs::LEVEL_RESET_PATH, &[], true)
1169            .await
1170            .map(|_| ())
1171    }
1172
1173    async fn restart(&self) -> Result<(), CoreError> {
1174        // `confirm=true` rides the QUERY string against an empty body
1175        // (the verified shape; recorded-request proof in
1176        // tests/restart_wait_contract.rs). Token-auth POSTs need no
1177        // CSRF (02-RESEARCH §Auth Model).
1178        let response = self
1179            .post_empty(
1180                restart::RESTART_PATH,
1181                &[("confirm", "true".to_string())],
1182                true,
1183            )
1184            .await?;
1185        // Success-shape drift guard: the verified body is the literal
1186        // `true`. Any other 2xx body still means the POST was accepted
1187        // — warn, don't fail (the wait half reports what happens next).
1188        let body = response.text().await.unwrap_or_default();
1189        if body.trim() != "true" {
1190            tracing::warn!(
1191                body = %body,
1192                "restart POST answered an unexpected 2xx body (expected the literal `true`)"
1193            );
1194        }
1195        Ok(())
1196    }
1197
1198    async fn scan_projects(&self) -> Result<(), CoreError> {
1199        self.post_empty(restart::SCAN_PROJECTS_PATH, &[], true)
1200            .await
1201            .map(|_| ())
1202    }
1203
1204    async fn security_properties(&self) -> Result<SecurityProperties, CoreError> {
1205        // The singleton READ (ADOPT-RESEARCH §2, live-corrected): the
1206        // record nests the permissions under `config` — deserialize
1207        // from there, tolerating a legacy flat record (config absent
1208        // → the record itself parses).
1209        let record: serde_json::Value = self
1210            .get_json(
1211                restart::SECURITY_PROPERTIES_PATH,
1212                Some(&[("defaultIfUndefined".to_string(), "true".to_string())]),
1213                true,
1214            )
1215            .await?;
1216        let config = record.get("config").cloned().unwrap_or(record);
1217        serde_json::from_value(config).map_err(|err| {
1218            CoreError::Internal(format!(
1219                "security-properties did not match the expected shape: {err}"
1220            ))
1221        })
1222    }
1223
1224    async fn webdev_route_status(&self, route: &str) -> Result<u16, CoreError> {
1225        // The raw-status probe: send, surface the status code, never
1226        // classify (404 vs 200/401/403 is the ANSWER, not an error).
1227        // Only transport failures (DNS/refused/timeout) error out.
1228        let path = restart::webdev_route_path(route);
1229        let url = self.url_for(&path);
1230        let request = self.apply_auth(self.client.get(url.clone()));
1231        let response = request.send().await.map_err(|err| CoreError::Network {
1232            url: url.to_string(),
1233            source: Some(err),
1234            observation: None,
1235        })?;
1236        Ok(response.status().as_u16())
1237    }
1238
1239    async fn webdev_route_call(
1240        &self,
1241        project: &str,
1242        route: &str,
1243        body: &serde_json::Value,
1244        extra_headers: &[(&str, &str)],
1245    ) -> Result<serde_json::Value, CoreError> {
1246        // classify() runs normally for transport/status errors; the
1247        // 200 BODY is then the route envelope — WebDev ignores
1248        // `status`, so denials ride HTTP 200 and the body verdict is
1249        // the ONLY success oracle (never the status line alone).
1250        let (url, response) = self
1251            .webdev_post_raw(project, route, body, extra_headers, None)
1252            .await?;
1253        let response = classify::classify(response, &url, false).await?;
1254        let text = response.text().await.unwrap_or_default();
1255        match webdev::parse_route_body(&text)? {
1256            RouteBody::Ok(data) => Ok(data),
1257            RouteBody::Denied {
1258                code,
1259                message,
1260                traceback,
1261            } => Err(webdev::denial_to_error(
1262                &code,
1263                &message,
1264                traceback.as_deref(),
1265                url,
1266            )),
1267        }
1268    }
1269
1270    async fn webdev_route_call_with_timeout(
1271        &self,
1272        project: &str,
1273        route: &str,
1274        body: &serde_json::Value,
1275        extra_headers: &[(&str, &str)],
1276        timeout: Duration,
1277    ) -> Result<serde_json::Value, CoreError> {
1278        // The SAME classify + envelope-parse tail as
1279        // `webdev_route_call` — the ONLY delta is the per-request
1280        // ceiling (behavior-identical request semantics).
1281        let (url, response) = self
1282            .webdev_post_raw(project, route, body, extra_headers, Some(timeout))
1283            .await?;
1284        let response = classify::classify(response, &url, false).await?;
1285        let text = response.text().await.unwrap_or_default();
1286        match webdev::parse_route_body(&text)? {
1287            RouteBody::Ok(data) => Ok(data),
1288            RouteBody::Denied {
1289                code,
1290                message,
1291                traceback,
1292            } => Err(webdev::denial_to_error(
1293                &code,
1294                &message,
1295                traceback.as_deref(),
1296                url,
1297            )),
1298        }
1299    }
1300
1301    async fn webdev_route_probe(
1302        &self,
1303        project: &str,
1304        route: &str,
1305        extra_headers: &[(&str, &str)],
1306    ) -> Result<RouteProbe, CoreError> {
1307        // NOT classified — the status code IS the answer (the
1308        // webdev_route_status precedent): 405/402/401 discriminate
1309        // presence/licensing/gating, and a 200 body carries the
1310        // version handshake or the structured denial.
1311        let (url, response) = self
1312            .webdev_post_raw(
1313                project,
1314                route,
1315                &serde_json::json!({"action": "version"}),
1316                extra_headers,
1317                None,
1318            )
1319            .await?;
1320        let status = response.status();
1321        if status.is_success() {
1322            let text = response.text().await.unwrap_or_default();
1323            return match webdev::parse_route_body(&text)? {
1324                RouteBody::Ok(data) => {
1325                    let route_version = data
1326                        .get("routeVersion")
1327                        .and_then(serde_json::Value::as_str)
1328                        .map(str::to_string)
1329                        .ok_or_else(|| {
1330                            CoreError::Internal(format!(
1331                                "webdev route version action from {url} answered no routeVersion"
1332                            ))
1333                        })?;
1334                    Ok(RouteProbe::Present { route_version })
1335                }
1336                RouteBody::Denied {
1337                    code,
1338                    message,
1339                    traceback,
1340                } => Ok(RouteProbe::Denied {
1341                    code,
1342                    message,
1343                    traceback,
1344                }),
1345            };
1346        }
1347        match status.as_u16() {
1348            401 | 403 => Ok(RouteProbe::AuthGated),
1349            402 => Ok(RouteProbe::Unlicensed),
1350            405 => Ok(RouteProbe::Absent),
1351            // Shapes the enum has no variant for (wizard redirects,
1352            // mid-restart 503s, foreign 404s) — reuse classify's
1353            // status mappings verbatim; every non-success response
1354            // classifies to Err, and the Ok arm is unreachable by
1355            // construction (all 2xx took the body branch above).
1356            _ => match classify::classify(response, &url, false).await {
1357                Err(err) => Err(err),
1358                Ok(_) => Err(CoreError::Internal(format!(
1359                    "unexpected HTTP {status} from webdev route probe at {url}"
1360                ))),
1361            },
1362        }
1363    }
1364
1365    async fn projects(
1366        &self,
1367        query: &query::ListQuery,
1368    ) -> Result<ListEnvelope<ProjectRecord>, CoreError> {
1369        // Standard list params (limit=-1 = the UI's "everything").
1370        self.get_json(
1371            projects::PROJECTS_LIST_PATH,
1372            Some(&query.to_query_pairs()),
1373            true,
1374        )
1375        .await
1376    }
1377
1378    async fn project_find(&self, name: &str) -> Result<ProjectRecord, CoreError> {
1379        // The {name} segment is percent-encoded (Pitfall 6) — the
1380        // spaced-name recorded-request proof in tests/projects_contract.rs.
1381        self.get_json(&projects::project_find_path(name), None, true)
1382            .await
1383    }
1384
1385    async fn project_create(&self, body: &ProjectCreate) -> Result<(), CoreError> {
1386        // Ok classification IS the success contract; callers that want
1387        // data re-`find` (the actions layer's read-back).
1388        self.post_json(projects::PROJECTS_CREATE_PATH, body)
1389            .await
1390            .map(|_| ())
1391    }
1392
1393    async fn project_copy(&self, from: &str, to: &str) -> Result<(), CoreError> {
1394        let body = ProjectCopy {
1395            from_name: from.to_string(),
1396            to_name: to.to_string(),
1397        };
1398        self.post_json(projects::PROJECTS_COPY_PATH, &body)
1399            .await
1400            .map(|_| ())
1401    }
1402
1403    async fn project_rename(&self, name: &str, new_name: &str) -> Result<(), CoreError> {
1404        let body = ProjectRenameBody {
1405            name: new_name.to_string(),
1406        };
1407        self.post_json(&projects::project_rename_path(name), &body)
1408            .await
1409            .map(|_| ())
1410    }
1411
1412    async fn project_modify(&self, name: &str, body: &ProjectModify) -> Result<(), CoreError> {
1413        self.put_json(&projects::project_modify_path(name), body)
1414            .await
1415    }
1416
1417    async fn project_delete(&self, name: &str) -> Result<(), CoreError> {
1418        // BOTH guard layers (Pitfall 8): the CLI already refused
1419        // without --yes (exit 2, pre-resolution) AND the wire request
1420        // always carries the server's own `confirm=true` query param
1421        // (wiremock recorded-request proof).
1422        self.delete_with_query(
1423            &projects::project_delete_path(name),
1424            &[("confirm", "true".to_string())],
1425        )
1426        .await
1427    }
1428
1429    async fn project_export_to_file(
1430        &self,
1431        name: &str,
1432        out: &Path,
1433    ) -> Result<ExportMeta, CoreError> {
1434        // The 120 s per-request override rides the RequestBuilder (the
1435        // logs-download precedent); the streaming itself lives in
1436        // download_to_file (classify FIRST, then chunk loop). No
1437        // `Accept` header — the export contract never named one.
1438        self.download_to_file(
1439            &projects::project_export_path(name),
1440            out,
1441            projects::PROJECT_EXPORT_TIMEOUT,
1442            None,
1443        )
1444        .await
1445    }
1446
1447    async fn project_import(
1448        &self,
1449        name: &str,
1450        zip: Vec<u8>,
1451        overwrite: bool,
1452    ) -> Result<ImportOutcome, CoreError> {
1453        // `overwrite` rides the QUERY string; the ZIP is the RAW body
1454        // with Content-Type application/zip and a known Content-Length
1455        // (Vec<u8> — chunked encoding never enters the picture). The
1456        // 300 s per-request override owns Pitfall 3. Token-auth POSTs
1457        // need no CSRF (02-RESEARCH §Auth Model).
1458        let url = self.url_for(&projects::project_import_path(name));
1459        let request = self
1460            .client
1461            .post(url.clone())
1462            .timeout(projects::PROJECT_IMPORT_TIMEOUT)
1463            .query(&[("overwrite", if overwrite { "true" } else { "false" })])
1464            .header(reqwest::header::CONTENT_TYPE, "application/zip")
1465            .body(zip);
1466        let request = self.apply_auth(request);
1467        let response = self.send_and_classify(request, &url).await?;
1468        // Opaque-success: parse the body when it is a JSON OBJECT,
1469        // else the fallback object (the body is unverified MEDIUM —
1470        // restart's `literal true` is the same family and normalizes
1471        // the same way, so agents always see a stable object shape).
1472        let body = response.text().await.unwrap_or_default();
1473        let parsed = serde_json::from_str::<serde_json::Value>(body.trim())
1474            .ok()
1475            .filter(|value| value.is_object())
1476            .unwrap_or_else(|| serde_json::json!({"status": "success"}));
1477        // Denial honesty (05-07, UAT Gap 1): the gateway refuses
1478        // imports over HTTP 200 with {success:false, problem} —
1479        // live-witnessed while NOTHING landed. ONE seam here fixes
1480        // every import caller at once (resource put/delete, project
1481        // import, webdev deploy) — per-caller checks are forbidden;
1482        // this IS the contract (the WebDev 200-denial precedent
1483        // applied to the import family).
1484        if let Some(problem) = projects::import_denied(&parsed) {
1485            return Err(CoreError::ImportDenied {
1486                project: name.to_string(),
1487                problem,
1488                endpoint: Some(url.to_string()),
1489            });
1490        }
1491        Ok(ImportOutcome { response: parsed })
1492    }
1493
1494    async fn tag_provider_list(
1495        &self,
1496        query: &query::ListQuery,
1497    ) -> Result<ListEnvelope<TagProviderRecord>, CoreError> {
1498        // Standard list params (limit=-1 = the UI's "everything") —
1499        // the connections-family resource lists' exact shape.
1500        self.get_json(
1501            tags::TAG_PROVIDERS_LIST_PATH,
1502            Some(&query.to_query_pairs()),
1503            true,
1504        )
1505        .await
1506    }
1507
1508    async fn tag_provider_find(&self, name: &str) -> Result<TagProviderRecord, CoreError> {
1509        self.get_json(&tags::tag_provider_find_path(name), None, true)
1510            .await
1511    }
1512
1513    async fn tag_provider_create(&self, body: &[TagProviderCreate]) -> Result<(), CoreError> {
1514        // The ARRAY body is the wire contract (a bare object 400s);
1515        // serde serializes elements in declaration order so the
1516        // recorded body is deterministic. Ok classification IS the
1517        // success contract (the project-create precedent).
1518        self.post_json(tags::TAG_PROVIDERS_CREATE_PATH, body)
1519            .await
1520            .map(|_| ())
1521    }
1522
1523    async fn tag_provider_delete(&self, name: &str, signature: &str) -> Result<(), CoreError> {
1524        // The signature rides the PATH (from find) — the
1525        // live-proven delete-by-signature chain; both segments
1526        // percent-encoded through the ONE locked encoder.
1527        self.delete_with_query(&tags::tag_provider_delete_path(name, signature), &[])
1528            .await
1529    }
1530
1531    async fn trial_status_wire(&self) -> Result<TrialWire, CoreError> {
1532        // Conditional auth: the endpoints answer unauthenticated
1533        // (live-verified both rigs), so a header-less client degrades
1534        // cleanly — but a carried credential rides along harmlessly
1535        // (future-proofing if a gateway version starts gating them).
1536        let auth = self.credential.is_some();
1537        self.get_json(trial::TRIAL_PATH, None, auth).await
1538    }
1539
1540    async fn banners(&self) -> Result<BannerSet, CoreError> {
1541        let auth = self.credential.is_some();
1542        self.get_json(trial::BANNERS_PATH, None, auth).await
1543    }
1544
1545    async fn trial_reset_wire(&self) -> Result<TrialWire, CoreError> {
1546        // Empty body, authed POST (the UI mutation's exact shape —
1547        // decompiled ia-gateway.js: {method:"POST",
1548        // url:"/data/api/v1/trial"}). Token-auth POSTs need no CSRF
1549        // (02-RESEARCH §Auth Model); on 403 the tier-1 session+CSRF
1550        // flow takes over (actions layer owns the ladder).
1551        let response = self.post_empty(trial::TRIAL_PATH, &[], true).await?;
1552        let body = response.text().await.unwrap_or_default();
1553        serde_json::from_str(&body).map_err(|err| {
1554            CoreError::Internal(format!(
1555                "trial reset response did not match the trial shape: {err}"
1556            ))
1557        })
1558    }
1559
1560    async fn backup_download(
1561        &self,
1562        out: &Path,
1563        backup_type: backup::BackupType,
1564    ) -> Result<ExportMeta, CoreError> {
1565        // Pure reuse: the type query rides the path builder, the
1566        // Accept header rides the helper's optional param, and the
1567        // 300 s class rides the RequestBuilder — the 03-02 chunk loop
1568        // stays THE one streaming body-consumption site (04-04).
1569        self.download_to_file(
1570            &backup::backup_download_path(backup_type),
1571            out,
1572            backup::BACKUP_TIMEOUT,
1573            Some(backup::BACKUP_ACCEPT),
1574        )
1575        .await
1576    }
1577
1578    async fn backup_restore(&self, gwbk: &Path) -> Result<(), CoreError> {
1579        // The upload direction buffers BY DESIGN (the import
1580        // precedent: a known Content-Length raw body sidesteps the
1581        // chunked-encoding question entirely). Token-auth POSTs need
1582        // no CSRF (02-RESEARCH §Auth Model). Ok classification IS the
1583        // acceptance contract — the actions layer owns the
1584        // post-restore RUNNING wait (Pitfall 6: the gateway restarts
1585        // after answering).
1586        let body = tokio::fs::read(gwbk)
1587            .await
1588            .map_err(|err| CoreError::InvalidInput {
1589                reason: format!("cannot read {}: {err}", gwbk.display()),
1590            })?;
1591        let url = self.url_for(backup::BACKUP_PATH);
1592        let request = self
1593            .client
1594            .post(url.clone())
1595            .timeout(backup::BACKUP_TIMEOUT)
1596            .query(&backup::restore_query())
1597            .header(reqwest::header::CONTENT_TYPE, "application/octet-stream")
1598            .body(body);
1599        let request = self.apply_auth(request);
1600        self.send_and_classify(request, &url).await.map(|_| ())
1601    }
1602
1603    async fn eam_task_history(
1604        &self,
1605        limit: Option<u32>,
1606        search: Option<&str>,
1607    ) -> Result<ListEnvelope<EamHistoryItem>, CoreError> {
1608        let query = query::ListQuery {
1609            limit: limit
1610                .map(i64::from)
1611                .unwrap_or(eam::EAM_HISTORY_DEFAULT_LIMIT),
1612            search: search.map(str::to_string),
1613            ..query::ListQuery::default()
1614        };
1615        self.get_json(eam::EAM_HISTORY_PATH, Some(&query.to_query_pairs()), true)
1616            .await
1617    }
1618
1619    async fn eam_task_definitions(&self) -> Result<ListEnvelope<EamTaskRecord>, CoreError> {
1620        // Standard list params (limit=-1 = the UI's "everything") —
1621        // definition counts are small; the connections-family
1622        // resource lists' exact shape.
1623        self.get_json(
1624            &eam::eam_tasks_list_path(),
1625            Some(&query::ListQuery::default().to_query_pairs()),
1626            true,
1627        )
1628        .await
1629    }
1630
1631    async fn eam_task_find(&self, name: &str) -> Result<EamTaskRecord, CoreError> {
1632        self.get_json(&eam::eam_task_find_path(name), None, true)
1633            .await
1634    }
1635
1636    async fn eam_task_create(&self, definition: &serde_json::Value) -> Result<(), CoreError> {
1637        // The ARRAY body is the wire contract (a bare object 400s —
1638        // the tag-provider create precedent); the caller's composed
1639        // definition rides as the single element. Ok classification
1640        // IS the success contract.
1641        self.post_json(&eam::eam_tasks_create_path(), &[definition])
1642            .await
1643            .map(|_| ())
1644    }
1645
1646    async fn eam_task_force(&self, owner: &str, name: &str) -> Result<(), CoreError> {
1647        // Empty body, authed POST — 204 is the live-proven success
1648        // shape; classify()'s 2xx pass-through IS the oracle (any
1649        // 2xx = dispatched; outcomes land in history as data).
1650        self.post_empty(&eam::eam_force_path(owner, name), &[], true)
1651            .await
1652            .map(|_| ())
1653    }
1654
1655    async fn eam_task_suspend(&self, name: &str) -> Result<(), CoreError> {
1656        // Empty body, authed POST — 204 is the captured success shape
1657        // (10-LIVE-CAPTURES §1b/§1c); the suspend/resume failure
1658        // modes are 500 HTML (classify → Internal with the page's
1659        // message — the module-doc finding; no 404 exists here).
1660        self.post_empty(&eam::eam_task_suspend_path(name), &[], true)
1661            .await
1662            .map(|_| ())
1663    }
1664
1665    async fn eam_task_resume(&self, name: &str) -> Result<(), CoreError> {
1666        // Same shape as suspend: 204 success (§1b/§1d — resume of a
1667        // never-suspended task is ALSO a 204; idempotent wire).
1668        self.post_empty(&eam::eam_task_resume_path(name), &[], true)
1669            .await
1670            .map(|_| ())
1671    }
1672
1673    async fn eam_task_cancel(&self, name: &str) -> Result<(), CoreError> {
1674        // 204 whether a pending execution existed, the task has
1675        // nothing pending, or the name is unknown (§7) — cancel is
1676        // wire-idempotent; name validation is the actions layer's
1677        // find-before-write job.
1678        self.post_empty(&eam::eam_task_cancel_path(name), &[], true)
1679            .await
1680            .map(|_| ())
1681    }
1682
1683    async fn eam_tasks_scheduled(&self, running: bool) -> Result<Vec<EamScheduledTask>, CoreError> {
1684        // The standard {items, metadata} envelope (§2 — byte-identical
1685        // shape across both rigs, incl. the empty-list quiet body);
1686        // this method unwraps the envelope to the items.
1687        let envelope: query::ListEnvelope<EamScheduledTask> = self
1688            .get_json(&eam::eam_tasks_scheduled_path(running), None, true)
1689            .await?;
1690        Ok(envelope.items)
1691    }
1692
1693    async fn eam_task_modify(
1694        &self,
1695        definition: &serde_json::Value,
1696    ) -> Result<Option<ModifyOutcome>, CoreError> {
1697        // The ARRAY body is the wire contract (single element — the
1698        // §6a capture; a bare object is not the shape the resource
1699        // PUT family speaks). The classify-first rule holds, then the
1700        // captured 200 body parses into ModifyOutcome (None = a 2xx
1701        // that carried no body — the lenient `Option` per the plan).
1702        let url = self.url_for(&eam::eam_tasks_modify_path());
1703        let request = self.apply_auth(self.client.put(url.clone()).json(&[definition]));
1704        let response = self.send_and_classify(request, &url).await?;
1705        let text = response.text().await.unwrap_or_default();
1706        if text.trim().is_empty() {
1707            return Ok(None);
1708        }
1709        serde_json::from_str(&text).map(Some).map_err(|err| {
1710            CoreError::Internal(format!(
1711                "response from {url} did not match the expected shape: {err}"
1712            ))
1713        })
1714    }
1715
1716    async fn eam_task_delete(
1717        &self,
1718        name: &str,
1719        signature: &str,
1720        confirm: bool,
1721    ) -> Result<DeleteOutcome, CoreError> {
1722        // `collection=core` ALWAYS (the captured success value — the
1723        // type token 404s, §3c); `confirm=true` rides ONLY on
1724        // explicit opt-in (a lone-resource delete succeeds without
1725        // it, §3b; the confirm-demand shape is unobserved, §3d —
1726        // never hard-coded). The 200 body is the captured
1727        // DeleteOutcome; classify-first as everywhere.
1728        let url = self.url_for(&eam::eam_task_delete_path(name, signature));
1729        let mut pairs: Vec<(&str, String)> = vec![("collection", "core".to_string())];
1730        if confirm {
1731            pairs.push(("confirm", "true".to_string()));
1732        }
1733        let request = self.apply_auth(self.client.delete(url.clone()).query(&pairs));
1734        let response = self.send_and_classify(request, &url).await?;
1735        let text = response.text().await.unwrap_or_default();
1736        serde_json::from_str(&text).map_err(|err| {
1737            CoreError::Internal(format!(
1738                "response from {url} did not match the expected shape: {err}"
1739            ))
1740        })
1741    }
1742
1743    async fn api_call(
1744        &self,
1745        call: &apicall::ApiCallRequest,
1746    ) -> Result<apicall::ApiCallData, CoreError> {
1747        let url = self.url_for(&call.path);
1748        // Any RFC verb (lowercase input normalized); an unparseable
1749        // method is a usage-class refusal BEFORE the wire — reqwest's
1750        // own `Method` parse is the validator (no hand-rolled list).
1751        let method =
1752            reqwest::Method::from_bytes(call.method.to_uppercase().as_bytes()).map_err(|_| {
1753                CoreError::InvalidInput {
1754                    reason: format!(
1755                        "{:?} is not a valid HTTP method — use an RFC verb \
1756                     (GET/POST/PUT/DELETE/PATCH/HEAD, …)",
1757                        call.method
1758                    ),
1759                }
1760            })?;
1761        let mut request = self.client.request(method, url.clone());
1762        for (name, value) in &call.headers {
1763            // reqwest's `.header()` PANICS on an invalid name/value —
1764            // these strings are user-supplied, so they are validated
1765            // HERE: a bad header is an exit-2 refusal, never a crash
1766            // (the webdev extra_headers loop never carried raw user
1767            // input; this one does).
1768            let name =
1769                reqwest::header::HeaderName::from_bytes(name.trim().as_bytes()).map_err(|_| {
1770                    CoreError::InvalidInput {
1771                        reason: format!("{name:?} is not a valid HTTP header name"),
1772                    }
1773                })?;
1774            let value = reqwest::header::HeaderValue::from_str(value).map_err(|_| {
1775                CoreError::InvalidInput {
1776                    reason: format!("{value:?} is not a valid HTTP header value"),
1777                }
1778            })?;
1779            request = request.header(name, value);
1780        }
1781        // The ONE query mechanism: reqwest's own serializer (the same
1782        // `.query(&pairs)` shape every other capability uses) — never
1783        // a `?` smuggled through the path (validate_path refuses it).
1784        if !call.query.is_empty() {
1785            request = request.query(&call.query);
1786        }
1787        if let Some(body) = &call.body {
1788            // Raw TEXT on ANY method — GET/DELETE body passthrough is
1789            // allowed (curl parity; the gateway's answer classifies).
1790            request = request.body(body.clone());
1791        }
1792        // THE auth site: profile credentials only (the redaction
1793        // boundary); the pipeline tail is the api-call-scoped
1794        // classifier so unclassified gateway 4xx ride
1795        // GatewayClientError instead of Internal.
1796        let request = self.apply_auth(request);
1797        let response = self.send_and_classify_for_api(request, &url).await?;
1798        let status = response.status().as_u16();
1799        let text = response.text().await.map_err(|err| CoreError::Network {
1800            url: url.to_string(),
1801            source: Some(err),
1802            observation: None,
1803        })?;
1804        // THE verbatim decision (research OQ1): `from_string` both
1805        // preserves the gateway's bytes (key order, unknown fields)
1806        // AND validates JSON in one call — no parse-re-serialize. A
1807        // non-JSON 2xx body is the documented internal-class honesty
1808        // refusal (binary endpoints ride the download pipelines).
1809        let data = serde_json::value::RawValue::from_string(text).map_err(|err| {
1810            CoreError::Internal(format!(
1811                "the gateway answered 2xx with a non-JSON body — api call returns \
1812                 JSON; use logs/backup downloads for binary endpoints ({err})"
1813            ))
1814        })?;
1815        Ok(apicall::ApiCallData { status, data })
1816    }
1817
1818    async fn license_status(&self) -> Result<LicenseStatusWire, CoreError> {
1819        // auth = true — a /data route under 8.3 default security (the
1820        // gateway-info rule); the capture session rode a token header.
1821        self.get_json(license::LICENSES_PATH, None, true).await
1822    }
1823
1824    async fn redundancy_status(&self) -> Result<RedundancyStatusWire, CoreError> {
1825        self.get_json(redundancy::REDUNDANCY_PATH, None, true).await
1826    }
1827
1828    async fn gan_status(&self) -> Result<GanStatusWire, CoreError> {
1829        self.get_json(gan::GAN_OVERVIEW_PATH, None, true).await
1830    }
1831
1832    async fn bundle_generate(&self) -> Result<BundleStatusWire, CoreError> {
1833        // Empty body, authed POST (the restart/set-logger precedent;
1834        // token mutations need no CSRF). The 200 body IS the status
1835        // wire per capture ({"state":"Generating"}) — classify-first
1836        // through the shared pipeline, then parse.
1837        let url = self.url_for(diagnostics::DIAGNOSTICS_GENERATE_PATH);
1838        let response = self
1839            .send_and_classify(self.apply_auth(self.client.post(url.clone())), &url)
1840            .await?;
1841        response.json::<BundleStatusWire>().await.map_err(|err| {
1842            CoreError::Internal(format!(
1843                "response from {url} did not match the expected shape: {err}"
1844            ))
1845        })
1846    }
1847
1848    async fn bundle_status(&self) -> Result<BundleStatusWire, CoreError> {
1849        // auth = true — a /data route under 8.3 default security; the
1850        // capture session rode a token header.
1851        self.get_json(diagnostics::DIAGNOSTICS_STATUS_PATH, None, true)
1852            .await
1853    }
1854
1855    async fn bundle_download(&self, out: &Path) -> Result<ExportMeta, CoreError> {
1856        // The 300 s per-request override rides the RequestBuilder
1857        // inside download_to_file (Pitfall 8: the 30 s client default
1858        // would truncate MB-sized bundles); the chunk loop stays THE
1859        // one streaming body-consumption site. No Accept header — the
1860        // capture named none (Content-Type: application/zip is the
1861        // answer). Disposition filename + content type ride ExportMeta.
1862        self.download_to_file(
1863            diagnostics::DIAGNOSTICS_DOWNLOAD_PATH,
1864            out,
1865            diagnostics::BUNDLE_DOWNLOAD_TIMEOUT,
1866            None,
1867        )
1868        .await
1869    }
1870}
1871
1872#[cfg(test)]
1873mod tests {
1874    use super::ReqwestGatewayApi;
1875
1876    /// Exercises `post_empty` end-to-end with the shape 02-04's
1877    /// set-logger-level route uses (query param + empty body): the
1878    /// verified restart shape is a 200 with literal body `true`,
1879    /// classified Ok — and the query param rides the request.
1880    #[tokio::test]
1881    async fn post_empty_sends_query_param_and_empty_body() {
1882        let server = wiremock::MockServer::start().await;
1883        let guard = wiremock::Mock::given(wiremock::matchers::method("POST"))
1884            .and(wiremock::matchers::path(
1885                "/data/api/v1/restart-tasks/restart",
1886            ))
1887            .and(wiremock::matchers::query_param("confirm", "true"))
1888            .respond_with(wiremock::ResponseTemplate::new(200).set_body_string("true"))
1889            .expect(1)
1890            .mount_as_scoped(&server)
1891            .await;
1892
1893        let api = ReqwestGatewayApi::for_tests(&server.uri(), None);
1894        let response = api
1895            .post_empty(
1896                "/data/api/v1/restart-tasks/restart",
1897                &[("confirm", "true".to_string())],
1898                true,
1899            )
1900            .await
1901            .expect("200 classifies Ok");
1902        assert_eq!(response.status(), reqwest::StatusCode::OK);
1903
1904        let requests = guard.received_requests().await;
1905        assert_eq!(requests.len(), 1);
1906        assert!(
1907            requests[0].body.is_empty(),
1908            "the POST carries NO body — params ride the query string"
1909        );
1910    }
1911}