1use serde::{Deserialize, Serialize};
11
12pub fn validate_token_name(name: &str) -> Result<(), &'static str> {
14 let name = name.trim();
15 if name.is_empty() {
16 Err("请输入令牌名称,不能只包含空白字符")
17 } else if name.chars().count() > 64 {
18 Err("令牌名称不能超过 64 个字符")
19 } else {
20 Ok(())
21 }
22}
23
24#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
33#[serde(rename_all = "lowercase")]
34pub enum TokenScope {
35 Read,
36 Write,
37 Admin,
38}
39
40impl TokenScope {
41 pub fn as_str(self) -> &'static str {
43 match self {
44 TokenScope::Read => "read",
45 TokenScope::Write => "write",
46 TokenScope::Admin => "admin",
47 }
48 }
49
50 pub fn from_db(s: &str) -> Option<Self> {
52 match s {
53 "read" => Some(TokenScope::Read),
54 "write" => Some(TokenScope::Write),
55 "admin" => Some(TokenScope::Admin),
56 _ => None,
57 }
58 }
59
60 fn rank(self) -> u8 {
62 match self {
63 TokenScope::Read => 1,
64 TokenScope::Write => 2,
65 TokenScope::Admin => 3,
66 }
67 }
68
69 pub fn grants(self, required: TokenScope) -> bool {
71 self.rank() >= required.rank()
72 }
73}
74
75impl PartialOrd for TokenScope {
76 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
77 Some(self.cmp(other))
78 }
79}
80
81impl Ord for TokenScope {
82 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
83 self.rank().cmp(&other.rank())
84 }
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
89pub struct McpToken {
90 pub id: String,
91 pub user_id: i32,
92 pub name: String,
93 pub scope: TokenScope,
94 pub notes: NoteGrant,
95 #[serde(skip)]
97 pub token_enc: String,
98 #[serde(skip)]
100 pub token_hash: String,
101 #[cfg_attr(
102 target_arch = "wasm32",
103 serde(deserialize_with = "crate::models::rfc3339::deserialize")
104 )]
105 pub created_at: chrono::DateTime<chrono::Utc>,
106 #[cfg_attr(
107 target_arch = "wasm32",
108 serde(
109 default,
110 deserialize_with = "crate::models::rfc3339::deserialize_option"
111 )
112 )]
113 pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
114 #[cfg_attr(
115 target_arch = "wasm32",
116 serde(
117 default,
118 deserialize_with = "crate::models::rfc3339::deserialize_option"
119 )
120 )]
121 pub last_used_at: Option<chrono::DateTime<chrono::Utc>>,
122 #[cfg_attr(
123 target_arch = "wasm32",
124 serde(
125 default,
126 deserialize_with = "crate::models::rfc3339::deserialize_option"
127 )
128 )]
129 pub revoked_at: Option<chrono::DateTime<chrono::Utc>>,
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
134pub struct McpTokenSummary {
135 pub id: String,
136 pub name: String,
137 pub scope: TokenScope,
138 #[serde(default)]
139 pub notes: NoteGrant,
140 #[cfg_attr(
141 target_arch = "wasm32",
142 serde(deserialize_with = "crate::models::rfc3339::deserialize")
143 )]
144 pub created_at: chrono::DateTime<chrono::Utc>,
145 #[cfg_attr(
146 target_arch = "wasm32",
147 serde(
148 default,
149 deserialize_with = "crate::models::rfc3339::deserialize_option"
150 )
151 )]
152 pub expires_at: Option<chrono::DateTime<chrono::Utc>>,
153 #[cfg_attr(
154 target_arch = "wasm32",
155 serde(
156 default,
157 deserialize_with = "crate::models::rfc3339::deserialize_option"
158 )
159 )]
160 pub last_used_at: Option<chrono::DateTime<chrono::Utc>>,
161 #[cfg_attr(
162 target_arch = "wasm32",
163 serde(
164 default,
165 deserialize_with = "crate::models::rfc3339::deserialize_option"
166 )
167 )]
168 pub revoked_at: Option<chrono::DateTime<chrono::Utc>>,
169}
170
171impl From<McpToken> for McpTokenSummary {
172 fn from(t: McpToken) -> Self {
173 Self {
174 id: t.id,
175 name: t.name,
176 scope: t.scope,
177 notes: t.notes,
178 created_at: t.created_at,
179 expires_at: t.expires_at,
180 last_used_at: t.last_used_at,
181 revoked_at: t.revoked_at,
182 }
183 }
184}
185
186#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
188pub struct NoteGrant {
189 pub read: bool,
190 pub write: bool,
191 pub notebook_ids: Option<Vec<i32>>,
192}
193
194#[derive(Debug, Clone, Serialize, PartialEq)]
199pub struct CreateTokenResponse {
200 #[serde(flatten)]
201 pub summary: McpTokenSummary,
202 pub plaintext: String,
204}
205
206impl<'de> Deserialize<'de> for CreateTokenResponse {
207 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
208 use chrono::{DateTime, Utc};
209
210 #[derive(Deserialize)]
211 struct Wire {
212 id: String,
213 name: String,
214 scope: TokenScope,
215 #[serde(default)]
216 notes: NoteGrant,
217 #[cfg_attr(
218 target_arch = "wasm32",
219 serde(deserialize_with = "crate::models::rfc3339::deserialize")
220 )]
221 created_at: DateTime<Utc>,
222 #[cfg_attr(
223 target_arch = "wasm32",
224 serde(
225 default,
226 deserialize_with = "crate::models::rfc3339::deserialize_option"
227 )
228 )]
229 expires_at: Option<DateTime<Utc>>,
230 #[cfg_attr(
231 target_arch = "wasm32",
232 serde(
233 default,
234 deserialize_with = "crate::models::rfc3339::deserialize_option"
235 )
236 )]
237 last_used_at: Option<DateTime<Utc>>,
238 #[cfg_attr(
239 target_arch = "wasm32",
240 serde(
241 default,
242 deserialize_with = "crate::models::rfc3339::deserialize_option"
243 )
244 )]
245 revoked_at: Option<DateTime<Utc>>,
246 plaintext: String,
247 }
248
249 let w = Wire::deserialize(d)?;
250 Ok(CreateTokenResponse {
251 summary: McpTokenSummary {
252 id: w.id,
253 name: w.name,
254 scope: w.scope,
255 notes: w.notes,
256 created_at: w.created_at,
257 expires_at: w.expires_at,
258 last_used_at: w.last_used_at,
259 revoked_at: w.revoked_at,
260 },
261 plaintext: w.plaintext,
262 })
263 }
264}
265
266#[cfg(test)]
267mod tests {
268 #[test]
269 fn token_name_requires_non_whitespace_and_at_most_64_characters() {
270 for name in ["", " \t\n", "\u{3000}"] {
271 assert!(super::validate_token_name(name).is_err());
272 }
273 for name in [" claude-code-macbook ".to_string(), "名".repeat(64)] {
274 assert!(super::validate_token_name(&name).is_ok());
275 }
276 assert!(super::validate_token_name(&"名".repeat(65)).is_err());
277 assert!(super::validate_token_name(&"a".repeat(65)).is_err());
278 }
279
280 use super::*;
281
282 #[test]
283 fn scope_ranking_grants_chain() {
284 assert!(TokenScope::Read.grants(TokenScope::Read));
286 assert!(!TokenScope::Read.grants(TokenScope::Write));
287 assert!(!TokenScope::Read.grants(TokenScope::Admin));
288 assert!(TokenScope::Write.grants(TokenScope::Read));
290 assert!(TokenScope::Write.grants(TokenScope::Write));
291 assert!(!TokenScope::Write.grants(TokenScope::Admin));
292 assert!(TokenScope::Admin.grants(TokenScope::Read));
294 assert!(TokenScope::Admin.grants(TokenScope::Write));
295 assert!(TokenScope::Admin.grants(TokenScope::Admin));
296 }
297
298 #[test]
299 fn scope_ord_total_order() {
300 assert!(TokenScope::Read < TokenScope::Write);
301 assert!(TokenScope::Write < TokenScope::Admin);
302 assert!(TokenScope::Admin >= TokenScope::Read);
303 let mut v = [TokenScope::Admin, TokenScope::Read, TokenScope::Write];
305 v.sort();
306 assert_eq!(v, [TokenScope::Read, TokenScope::Write, TokenScope::Admin]);
307 }
308
309 #[test]
310 fn scope_db_roundtrip() {
311 for s in [TokenScope::Read, TokenScope::Write, TokenScope::Admin] {
312 assert_eq!(TokenScope::from_db(s.as_str()), Some(s));
313 }
314 assert_eq!(TokenScope::from_db("root"), None);
315 assert_eq!(TokenScope::from_db(""), None);
316 }
317
318 #[test]
319 fn scope_serde_lowercase() {
320 let s = serde_json::to_string(&TokenScope::Admin).unwrap();
321 assert_eq!(s, "\"admin\"");
322 let v: TokenScope = serde_json::from_str("\"read\"").unwrap();
323 assert_eq!(v, TokenScope::Read);
324 }
325
326 #[test]
327 fn create_token_response_round_trips_flat_json() {
328 let response = CreateTokenResponse {
329 summary: McpTokenSummary {
330 id: "00000000-0000-0000-0000-000000000001".into(),
331 name: "claude-code".into(),
332 scope: TokenScope::Write,
333 notes: NoteGrant {
334 read: true,
335 write: false,
336 notebook_ids: Some(vec![3]),
337 },
338 created_at: chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap(),
339 expires_at: None,
340 last_used_at: None,
341 revoked_at: None,
342 },
343 plaintext: "ygg_secret".into(),
344 };
345 let json = serde_json::to_value(&response).unwrap();
346 assert_eq!(json["plaintext"], "ygg_secret");
347 assert_eq!(json["id"], "00000000-0000-0000-0000-000000000001");
348 assert_eq!(
349 serde_json::from_value::<CreateTokenResponse>(json).unwrap(),
350 response
351 );
352 }
353}