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