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}