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