Skip to main content

kanade_shared/ipc/
support.rs

1//! `support.*` types — the helpdesk-facing corner of KLP.
2//!
3//! Two features share the namespace:
4//!
5//! - `support.upload_diagnostics` — one-click "サポートに問い合わせる"
6//!   diagnostics bundle. Per SPEC §2.1: the agent collects
7//!   `{pc_id, recent_inventory, last_N_events, agent_log_tail}`, zips
8//!   them, uploads to the JetStream Object Store, and the backend opens a
9//!   helpdesk ticket. The Client App shows the resulting ticket URL so
10//!   the user can paste it into the chat / email follow-up.
11//! - `support.unlock` / `support.lock` / `support.status` — the
12//!   operator-code display gate in front of `client.unlock`-scoped jobs: the
13//!   IT desk types a secret code on the user's PC to reveal actions that have
14//!   no business sitting in that user's everyday catalog. Listing-only — see
15//!   `kanade_shared::manifest::ClientHint::unlock`.
16
17use serde::{Deserialize, Serialize};
18
19/// `support.upload_diagnostics` params — optional user-supplied
20/// context so the helpdesk has triage info at ticket-open time
21/// without a second round-trip.
22#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default)]
23pub struct SupportUploadDiagnosticsParams {
24    /// One-line summary the user typed into the support form
25    /// (e.g. "Teams won't open since the update"). May be empty.
26    #[serde(default)]
27    pub summary: String,
28    /// Optional longer description / repro steps.
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub detail: Option<String>,
31    /// User's contact preference (email / Teams handle / phone).
32    /// Free-form because organisations differ; the SPA presents a
33    /// drop-down but stores the chosen value as a string.
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub contact: Option<String>,
36}
37
38/// `support.upload_diagnostics` response.
39#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
40pub struct SupportUploadDiagnosticsResult {
41    /// JetStream Object Store key for the uploaded zip — used by
42    /// the helpdesk's tooling to fetch the bundle without
43    /// re-asking the user to attach it.
44    pub object_key: String,
45    /// Ticket id from whichever helpdesk system the backend
46    /// integrated with (Jira, ServiceNow, …). `None` when the
47    /// upload succeeded but ticket creation deferred (the backend
48    /// retries asynchronously).
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub ticket_id: Option<String>,
51    /// User-friendly URL to view the ticket (or, when `ticket_id`
52    /// is None, a generic "your diagnostics have been uploaded"
53    /// landing page). The Client App shows this as the post-submit
54    /// confirmation.
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub ticket_url: Option<String>,
57    /// Size of the uploaded zip in bytes. Surfaced so the SPA can
58    /// show "Uploaded 4.2 MB" — reassuring proof that the bundle
59    /// went through.
60    pub size_bytes: u64,
61}
62
63// ---------- support.unlock / support.lock / support.status ----------
64
65/// One live unlock grant — every `client.unlock: <scope>` job is listed for
66/// the caller until `expires_at`.
67///
68/// Grants are held per **OS user** (the connection's SID), not per
69/// connection: the Client App reconnects on its own (a pipe hiccup, a
70/// restart), and silently re-locking mid-support-call would be a
71/// baffling failure mode. `support.status` lets a reconnecting client
72/// recover the banner without re-asking for the code.
73#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq, Eq)]
74pub struct UnlockGrant {
75    /// The `client.unlock` scope this grant opens (`support`, `admin`, …).
76    pub scope: String,
77    /// Operator-supplied human label for the code that opened it (from
78    /// `ServerSettings::support_codes[].label`), so the Client App banner
79    /// can read「サポートモード(ヘルプデスク)」rather than a bare slug.
80    /// `None` when the operator left it unset.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub label: Option<String>,
83    /// When the grant lapses on its own. The Client App counts down to it
84    /// and re-locks its UI at zero; the agent enforces it independently at
85    /// every `jobs.list` / `jobs.execute`, so a client that ignores the
86    /// deadline gains nothing.
87    pub expires_at: chrono::DateTime<chrono::Utc>,
88}
89
90/// `support.unlock` params — the code the IT desk typed into the Client
91/// App. Compared against the argon2id hashes in
92/// `ServerSettings::support_codes`; the plaintext never leaves the agent
93/// process (it is not logged, and audit events record only the outcome).
94#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
95pub struct SupportUnlockParams {
96    pub code: String,
97}
98
99/// `support.unlock` response — every grant the caller holds after the
100/// redeem, not just the newly-added one, so the client can render the
101/// whole banner from one reply.
102///
103/// A wrong code does NOT come back here: it's an
104/// [`ErrorKind::Unauthorized`](crate::ipc::error::ErrorKind::Unauthorized)
105/// error, and repeated failures escalate to
106/// [`ErrorKind::RateLimit`](crate::ipc::error::ErrorKind::RateLimit).
107#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
108pub struct SupportUnlockResult {
109    pub grants: Vec<UnlockGrant>,
110}
111
112/// `support.lock` params — no selectors: locking is all-or-nothing. The
113/// button means "I'm done, close it all", and a partial lock would leave
114/// a scope open that the banner no longer advertises.
115#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default)]
116pub struct SupportLockParams {}
117
118/// `support.lock` response — how many grants were dropped (0 when the
119/// caller held none; locking is idempotent, never an error).
120#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
121pub struct SupportLockResult {
122    pub released: usize,
123}
124
125/// `support.status` params — no selectors; the answer is always "what
126/// does the calling OS user hold right now".
127#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default)]
128pub struct SupportStatusParams {}
129
130/// `support.status` response — the caller's live grants (empty ⇒ locked).
131/// Expired grants are swept before answering, so every entry here is
132/// currently in force.
133#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
134pub struct SupportStatusResult {
135    pub grants: Vec<UnlockGrant>,
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141
142    #[test]
143    fn params_default_is_empty_summary() {
144        let p = SupportUploadDiagnosticsParams::default();
145        assert_eq!(p.summary, "");
146        assert!(p.detail.is_none());
147        assert!(p.contact.is_none());
148    }
149
150    #[test]
151    fn params_minimal_wire_decodes() {
152        let p: SupportUploadDiagnosticsParams = serde_json::from_str("{}").unwrap();
153        assert_eq!(p.summary, "");
154    }
155
156    #[test]
157    fn result_with_ticket_round_trips() {
158        let r = SupportUploadDiagnosticsResult {
159            object_key: "support/2026-05-24/abc123.zip".into(),
160            ticket_id: Some("HELP-42".into()),
161            ticket_url: Some("https://helpdesk.example.com/tickets/HELP-42".into()),
162            size_bytes: 4_200_000,
163        };
164        let json = serde_json::to_string(&r).unwrap();
165        let back: SupportUploadDiagnosticsResult = serde_json::from_str(&json).unwrap();
166        assert_eq!(back.object_key, r.object_key);
167        assert_eq!(back.ticket_id, r.ticket_id);
168        assert_eq!(back.ticket_url, r.ticket_url);
169        assert_eq!(back.size_bytes, r.size_bytes);
170    }
171
172    #[test]
173    fn result_without_ticket_omits_field_on_wire() {
174        // Deferred-ticket path: object uploaded, ticket id pending.
175        // SPA UI key: ticket_id absence ⇒ "uploaded, ticket
176        // pending". Wire MUST be field-absent, not null.
177        let r = SupportUploadDiagnosticsResult {
178            object_key: "x".into(),
179            ticket_id: None,
180            ticket_url: None,
181            size_bytes: 0,
182        };
183        let v = serde_json::to_value(&r).unwrap();
184        assert!(v.get("ticket_id").is_none(), "wire: {v:?}");
185        assert!(v.get("ticket_url").is_none(), "wire: {v:?}");
186    }
187
188    #[test]
189    fn unlock_grant_omits_absent_label() {
190        // The banner falls back to the scope slug when the operator left
191        // `label` unset — the wire must be field-absent, not null (strict
192        // JS clients reject `null` where they expect `string | undefined`).
193        let g = UnlockGrant {
194            scope: "support".into(),
195            label: None,
196            expires_at: chrono::Utc::now(),
197        };
198        let v = serde_json::to_value(&g).unwrap();
199        assert_eq!(v["scope"], "support");
200        assert!(v.get("label").is_none(), "wire: {v:?}");
201    }
202
203    #[test]
204    fn unlock_result_round_trips() {
205        let wire = r#"{"grants":[
206            {"scope":"support","label":"ヘルプデスク","expires_at":"2026-07-28T09:00:00Z"}
207        ]}"#;
208        let r: SupportUnlockResult = serde_json::from_str(wire).unwrap();
209        assert_eq!(r.grants.len(), 1);
210        assert_eq!(r.grants[0].scope, "support");
211        assert_eq!(r.grants[0].label.as_deref(), Some("ヘルプデスク"));
212    }
213
214    #[test]
215    fn status_result_empty_means_locked() {
216        // No grants ⇒ an empty array, never a missing key: the client
217        // treats `grants.length === 0` as "locked" and must not have to
218        // distinguish that from a malformed reply.
219        let r = SupportStatusResult { grants: vec![] };
220        let v = serde_json::to_value(&r).unwrap();
221        assert_eq!(v["grants"], serde_json::json!([]));
222    }
223}