Skip to main content

cloudillo_types/
types.rs

1// SPDX-FileCopyrightText: Szilárd Hajba
2// SPDX-License-Identifier: LGPL-3.0-or-later
3
4//! Common types used throughout the Cloudillo platform.
5
6use crate::abac::AttrSet;
7use serde::{Deserialize, Deserializer, Serialize, Serializer};
8use serde_with::skip_serializing_none;
9use std::collections::HashMap;
10use std::time::SystemTime;
11
12// TnId //
13//******//
14//pub type TnId = u32;
15#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
16pub struct TnId(pub u32);
17
18impl std::fmt::Display for TnId {
19	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20		write!(f, "{}", self.0)
21	}
22}
23
24impl Serialize for TnId {
25	fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
26	where
27		S: serde::Serializer,
28	{
29		serializer.serialize_u32(self.0)
30	}
31}
32
33impl<'de> Deserialize<'de> for TnId {
34	fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
35	where
36		D: serde::Deserializer<'de>,
37	{
38		Ok(TnId(u32::deserialize(deserializer)?))
39	}
40}
41
42// Timestamp //
43//***********//
44//pub type Timestamp = u32;
45#[derive(Clone, Copy, Debug, Default)]
46pub struct Timestamp(pub i64);
47
48impl Timestamp {
49	pub fn now() -> Timestamp {
50		let res = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap_or_default();
51		Timestamp(res.as_secs().cast_signed())
52	}
53
54	pub fn from_now(delta: i64) -> Timestamp {
55		let res = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap_or_default();
56		Timestamp(res.as_secs().cast_signed() + delta)
57	}
58
59	/// Add seconds to this timestamp
60	pub fn add_seconds(&self, seconds: i64) -> Timestamp {
61		Timestamp(self.0 + seconds)
62	}
63
64	/// Format as ISO 8601 string (e.g. "2024-01-15T12:00:00Z")
65	pub fn to_iso_string(&self) -> String {
66		use chrono::{DateTime, SecondsFormat};
67		DateTime::from_timestamp(self.0, 0)
68			.unwrap_or_default()
69			.to_rfc3339_opts(SecondsFormat::Secs, true)
70	}
71}
72
73impl std::fmt::Display for Timestamp {
74	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75		write!(f, "{}", self.0)
76	}
77}
78
79impl std::cmp::PartialEq for Timestamp {
80	fn eq(&self, other: &Self) -> bool {
81		self.0 == other.0
82	}
83}
84
85impl std::cmp::PartialOrd for Timestamp {
86	fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
87		Some(self.cmp(other))
88	}
89}
90
91impl std::cmp::Eq for Timestamp {}
92
93impl std::cmp::Ord for Timestamp {
94	fn cmp(&self, other: &Self) -> std::cmp::Ordering {
95		self.0.cmp(&other.0)
96	}
97}
98
99impl Serialize for Timestamp {
100	fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
101	where
102		S: serde::Serializer,
103	{
104		serializer.serialize_i64(self.0)
105	}
106}
107
108impl<'de> Deserialize<'de> for Timestamp {
109	fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
110	where
111		D: serde::Deserializer<'de>,
112	{
113		use serde::de::{Error, Visitor};
114
115		struct TimestampVisitor;
116
117		impl Visitor<'_> for TimestampVisitor {
118			type Value = Timestamp;
119
120			fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
121				write!(f, "an integer timestamp or ISO 8601 string")
122			}
123
124			fn visit_i64<E: Error>(self, v: i64) -> Result<Self::Value, E> {
125				Ok(Timestamp(v))
126			}
127
128			fn visit_u64<E: Error>(self, v: u64) -> Result<Self::Value, E> {
129				Ok(Timestamp(v.cast_signed()))
130			}
131
132			fn visit_str<E: Error>(self, v: &str) -> Result<Self::Value, E> {
133				use chrono::DateTime;
134				DateTime::parse_from_rfc3339(v)
135					.map(|dt| Timestamp(dt.timestamp()))
136					.map_err(|_| E::custom("invalid ISO 8601 timestamp"))
137			}
138		}
139
140		deserializer.deserialize_any(TimestampVisitor)
141	}
142}
143
144/// Serialize Timestamp as ISO 8601 string for API responses
145pub fn serialize_timestamp_iso<S>(ts: &Timestamp, serializer: S) -> Result<S::Ok, S::Error>
146where
147	S: Serializer,
148{
149	use chrono::{DateTime, SecondsFormat};
150	let dt = DateTime::from_timestamp(ts.0, 0).unwrap_or_default();
151	serializer.serialize_str(&dt.to_rfc3339_opts(SecondsFormat::Secs, true))
152}
153
154/// Serialize Option<Timestamp> as ISO 8601 string for API responses
155pub fn serialize_timestamp_iso_opt<S>(
156	ts: &Option<Timestamp>,
157	serializer: S,
158) -> Result<S::Ok, S::Error>
159where
160	S: Serializer,
161{
162	match ts {
163		Some(ts) => serialize_timestamp_iso(ts, serializer),
164		None => serializer.serialize_none(),
165	}
166}
167
168// Patch<T> - For PATCH semantics //
169//**********************************//
170/// Represents a field in a PATCH request with three states:
171/// - `Undefined`: Field not present in JSON - don't change existing value
172/// - `Null`: Field present with null value - set to NULL in database
173/// - `Value(T)`: Field present with value - update to this value
174#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
175pub enum Patch<T> {
176	/// Field not present in request - no change
177	#[default]
178	Undefined,
179	/// Field present with null value - delete/set to NULL
180	Null,
181	/// Field present with value - update to this value
182	Value(T),
183}
184
185impl<T> Patch<T> {
186	/// Returns true if this is `Undefined`
187	pub fn is_undefined(&self) -> bool {
188		matches!(self, Patch::Undefined)
189	}
190
191	/// Returns true if this is `Null`
192	pub fn is_null(&self) -> bool {
193		matches!(self, Patch::Null)
194	}
195
196	/// Returns true if this is `Value(_)`
197	pub fn is_value(&self) -> bool {
198		matches!(self, Patch::Value(_))
199	}
200
201	/// Returns the value if `Value`, otherwise None
202	pub fn value(&self) -> Option<&T> {
203		match self {
204			Patch::Value(v) => Some(v),
205			_ => None,
206		}
207	}
208
209	/// Converts to Option: Undefined -> None, Null -> Some(None), Value(v) -> Some(Some(v))
210	pub fn as_option(&self) -> Option<Option<&T>> {
211		match self {
212			Patch::Undefined => None,
213			Patch::Null => Some(None),
214			Patch::Value(v) => Some(Some(v)),
215		}
216	}
217
218	/// Maps a `Patch<T>` to `Patch<U>` by applying a function to the contained value
219	pub fn map<U, F>(self, f: F) -> Patch<U>
220	where
221		F: FnOnce(T) -> U,
222	{
223		match self {
224			Patch::Undefined => Patch::Undefined,
225			Patch::Null => Patch::Null,
226			Patch::Value(v) => Patch::Value(f(v)),
227		}
228	}
229}
230
231impl<T> Serialize for Patch<T>
232where
233	T: Serialize,
234{
235	fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
236	where
237		S: Serializer,
238	{
239		match self {
240			Patch::Undefined | Patch::Null => serializer.serialize_none(),
241			Patch::Value(v) => v.serialize(serializer),
242		}
243	}
244}
245
246impl<'de, T> Deserialize<'de> for Patch<T>
247where
248	T: Deserialize<'de>,
249{
250	fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
251	where
252		D: Deserializer<'de>,
253	{
254		Option::<T>::deserialize(deserializer).map(|opt| match opt {
255			None => Patch::Null,
256			Some(v) => Patch::Value(v),
257		})
258	}
259}
260
261// Phase 1: Authentication & Profile Types
262//******************************************
263
264/// Registration type and verification request
265#[derive(Debug, Clone, Serialize, Deserialize)]
266#[serde(rename_all = "camelCase")]
267pub struct RegisterVerifyCheckRequest {
268	#[serde(rename = "type")]
269	pub typ: String, // "idp" or "domain"
270	pub id_tag: String,
271	pub app_domain: Option<String>,
272	pub token: Option<String>, // Optional: Required for unauthenticated requests
273}
274
275/// Registration request with account creation
276#[derive(Debug, Clone, Serialize, Deserialize)]
277#[serde(rename_all = "camelCase")]
278pub struct RegisterRequest {
279	#[serde(rename = "type")]
280	pub typ: String, // "idp" or "domain"
281	pub id_tag: String,
282	pub app_domain: Option<String>,
283	pub email: String,
284	pub token: String,
285	pub lang: Option<String>,
286}
287
288/// Registration verification request (legacy, kept for compatibility)
289#[derive(Debug, Clone, Serialize, Deserialize)]
290#[serde(rename_all = "camelCase")]
291pub struct RegisterVerifyRequest {
292	pub id_tag: String,
293	pub token: String,
294}
295
296/// Public profile wire type for federated profile exchange
297#[serde_with::skip_serializing_none]
298#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
299#[serde(rename_all = "camelCase")]
300pub struct Profile {
301	pub id_tag: String,
302	pub name: String,
303	#[serde(rename = "type")]
304	pub r#type: String,
305	pub profile_pic: Option<String>,
306	pub cover_pic: Option<String>,
307	pub keys: Vec<crate::auth_adapter::AuthKey>,
308	/// Extensible metadata (profile sections, tab config, etc.)
309	pub x: Option<HashMap<String, String>>,
310}
311
312/// Terse self-profile shape returned by `/api/me`.
313///
314/// Used for server-to-server federation sync (base profile fields + signing
315/// keys). Deliberately omits the `x` extension map that `Profile` carries —
316/// peers don't need tier-filtered UI metadata.
317#[serde_with::skip_serializing_none]
318#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
319#[serde(rename_all = "camelCase")]
320pub struct ProfileBase {
321	pub id_tag: String,
322	pub name: String,
323	#[serde(rename = "type")]
324	pub r#type: String,
325	pub profile_pic: Option<String>,
326	pub keys: Vec<crate::auth_adapter::AuthKey>,
327}
328
329/// Profile patch for PATCH /me endpoint
330#[derive(Debug, Clone, Serialize, Deserialize)]
331#[serde(rename_all = "camelCase")]
332pub struct ProfilePatch {
333	#[serde(default)]
334	pub name: Patch<String>,
335	/// Extensible metadata fields (partial merge: existing keys preserved, null deletes)
336	#[serde(default)]
337	pub x: Option<HashMap<String, Option<String>>>,
338}
339
340/// Admin profile patch for PATCH /admin/profile/:idTag endpoint
341#[derive(Debug, Clone, Serialize, Deserialize)]
342#[serde(rename_all = "camelCase")]
343pub struct AdminProfilePatch {
344	// Basic profile fields
345	#[serde(default)]
346	pub name: Patch<String>,
347
348	// Administrative fields
349	#[serde(default)]
350	pub roles: Patch<Option<Vec<String>>>,
351	#[serde(default)]
352	pub status: Patch<crate::meta_adapter::ProfileStatus>,
353}
354
355/// Profile information response
356#[skip_serializing_none]
357#[derive(Debug, Clone, Serialize, Deserialize)]
358#[serde(rename_all = "camelCase")]
359pub struct ProfileInfo {
360	pub id_tag: String,
361	pub name: String,
362	#[serde(rename = "type")]
363	pub r#type: Option<String>,
364	pub profile_pic: Option<String>, // file_id
365	pub status: Option<crate::meta_adapter::ProfileStatus>,
366	pub connected: Option<bool>,
367	pub following: Option<bool>,
368	/// Per-profile trust preference controlling proxy-token use on passive reads.
369	/// `"always"` = always authenticate, `"never"` = never, absent = ask.
370	pub trust: Option<crate::meta_adapter::ProfileTrust>,
371	pub roles: Option<Vec<String>>,
372	#[serde(
373		serialize_with = "serialize_timestamp_iso_opt",
374		skip_serializing_if = "Option::is_none"
375	)]
376	pub created_at: Option<Timestamp>,
377	/// Extensible metadata (profile sections, tab config, etc.)
378	pub x: Option<HashMap<String, String>>,
379}
380
381/// Request body for community profile creation
382#[derive(Debug, Clone, Deserialize)]
383#[serde(rename_all = "camelCase")]
384pub struct CreateCommunityRequest {
385	#[serde(rename = "type")]
386	pub typ: String, // "idp" or "domain" - identity type
387	pub name: Option<String>,
388	pub profile_pic: Option<String>,
389	pub app_domain: Option<String>, // For domain type
390	pub invite_ref: Option<String>, // Invite ref code for community creation
391}
392
393/// Response for community profile creation
394#[skip_serializing_none]
395#[derive(Debug, Clone, Serialize)]
396#[serde(rename_all = "camelCase")]
397pub struct CommunityProfileResponse {
398	pub id_tag: String,
399	pub name: String,
400	#[serde(rename = "type")]
401	pub r#type: String,
402	pub profile_pic: Option<String>,
403	#[serde(serialize_with = "serialize_timestamp_iso")]
404	pub created_at: Timestamp,
405	/// Initial value of the new community tenant's `ui.onboarding` setting.
406	/// `Some("verify-idp")` for an IDP-typed community whose IDP identity is
407	/// still pending; `None` for domain-typed (or already-active) communities.
408	/// The frontend uses this to decide whether to mark the community as
409	/// pending in the sidebar and show the activation banner.
410	pub onboarding: Option<String>,
411}
412
413// Phase 2: Action Management & File Integration
414//***********************************************
415
416/// Action creation request
417#[derive(Debug, Clone, Serialize, Deserialize)]
418#[serde(rename_all = "camelCase")]
419pub struct CreateActionRequest {
420	#[serde(rename = "type")]
421	pub r#type: String, // "Create", "Update", etc
422	pub sub_type: Option<String>, // "Note", "Image", etc
423	pub parent_id: Option<String>,
424	// Note: root_id is auto-populated from parent chain, not specified by clients
425	pub content: String,
426	pub attachments: Option<Vec<String>>, // file_ids
427	pub audience: Option<Vec<String>>,
428}
429
430/// Action response (API layer)
431#[skip_serializing_none]
432#[derive(Debug, Clone, Serialize, Deserialize)]
433#[serde(rename_all = "camelCase")]
434pub struct ActionResponse {
435	pub action_id: String,
436	pub action_token: String,
437	#[serde(rename = "type")]
438	pub r#type: String,
439	pub sub_type: Option<String>,
440	pub parent_id: Option<String>,
441	pub root_id: Option<String>,
442	pub content: String,
443	pub attachments: Vec<String>,
444	pub issuer_tag: String,
445	#[serde(serialize_with = "serialize_timestamp_iso")]
446	pub created_at: Timestamp,
447}
448
449/// List actions query parameters
450#[derive(Debug, Clone, Default, Deserialize)]
451#[serde(rename_all = "camelCase")]
452pub struct ListActionsQuery {
453	#[serde(rename = "type")]
454	pub r#type: Option<String>,
455	pub parent_id: Option<String>,
456	pub offset: Option<usize>,
457	pub limit: Option<usize>,
458}
459
460/// File upload response
461#[derive(Debug, Clone, Serialize, Deserialize)]
462#[serde(rename_all = "camelCase")]
463pub struct FileUploadResponse {
464	pub file_id: String,
465	pub descriptor: String,
466	pub variants: Vec<FileVariantInfo>,
467}
468
469/// File variant information
470#[derive(Debug, Clone, Serialize, Deserialize)]
471#[serde(rename_all = "camelCase")]
472pub struct FileVariantInfo {
473	pub variant_id: String,
474	pub format: String,
475	pub size: u64,
476	pub resolution: Option<(u32, u32)>,
477}
478
479/// Tag information with optional usage count
480#[derive(Debug, Clone, Serialize, Deserialize)]
481pub struct TagInfo {
482	pub tag: String,
483	#[serde(skip_serializing_if = "Option::is_none")]
484	pub count: Option<u32>,
485}
486
487// Phase 1: API Response Envelope & Error Types
488//***********************************************
489
490/// Pagination information for list responses (offset-based - deprecated)
491#[derive(Debug, Clone, Serialize, Deserialize)]
492#[serde(rename_all = "camelCase")]
493pub struct PaginationInfo {
494	pub offset: usize,
495	pub limit: usize,
496	pub total: usize,
497}
498
499/// Cursor-based pagination information for list responses
500///
501/// Provides stable pagination that handles data changes between requests.
502/// The cursor is an opaque base64-encoded JSON containing sort field, value, and last item ID.
503#[derive(Debug, Clone, Serialize, Deserialize)]
504#[serde(rename_all = "camelCase")]
505pub struct CursorPaginationInfo {
506	/// Opaque cursor for fetching next page (None if no more results)
507	pub next_cursor: Option<String>,
508	/// Whether more results are available
509	pub has_more: bool,
510}
511
512/// Cursor data structure (encoded as base64 JSON in API)
513#[derive(Debug, Clone, Serialize, Deserialize)]
514pub struct CursorData {
515	/// Sort field: "created", "modified", "recent", "name"
516	pub s: String,
517	/// Sort value (timestamp as i64 or string for name)
518	pub v: serde_json::Value,
519	/// Last item's external ID (file_id or action_id)
520	pub id: String,
521}
522
523impl CursorData {
524	/// Create a new cursor from sort field, value, and item ID
525	pub fn new(sort_field: &str, sort_value: serde_json::Value, item_id: &str) -> Self {
526		Self { s: sort_field.to_string(), v: sort_value, id: item_id.to_string() }
527	}
528
529	/// Encode cursor to base64 string
530	pub fn encode(&self) -> String {
531		use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
532		let json = serde_json::to_string(self).unwrap_or_default();
533		URL_SAFE_NO_PAD.encode(json.as_bytes())
534	}
535
536	/// Decode cursor from base64 string
537	pub fn decode(cursor: &str) -> Option<Self> {
538		use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
539		let bytes = URL_SAFE_NO_PAD.decode(cursor).ok()?;
540		let json = String::from_utf8(bytes).ok()?;
541		serde_json::from_str(&json).ok()
542	}
543
544	/// Get sort value as i64 timestamp (for date fields)
545	pub fn timestamp(&self) -> Option<i64> {
546		self.v.as_i64()
547	}
548
549	/// Get sort value as string (for name field)
550	pub fn string_value(&self) -> Option<&str> {
551		self.v.as_str()
552	}
553}
554
555/// Success response envelope for single objects
556#[derive(Debug, Serialize, Deserialize)]
557#[serde(rename_all = "camelCase")]
558pub struct ApiResponse<T> {
559	pub data: T,
560	#[serde(skip_serializing_if = "Option::is_none")]
561	pub pagination: Option<PaginationInfo>,
562	#[serde(skip_serializing_if = "Option::is_none")]
563	pub cursor_pagination: Option<CursorPaginationInfo>,
564	#[serde(serialize_with = "serialize_timestamp_iso")]
565	pub time: Timestamp,
566	#[serde(skip_serializing_if = "Option::is_none")]
567	pub req_id: Option<String>,
568}
569
570impl<T> ApiResponse<T> {
571	/// Create a new response with data and current time
572	pub fn new(data: T) -> Self {
573		Self {
574			data,
575			pagination: None,
576			cursor_pagination: None,
577			time: Timestamp::now(),
578			req_id: None,
579		}
580	}
581
582	/// Create a response with offset-based pagination info (deprecated)
583	pub fn with_pagination(data: T, offset: usize, limit: usize, total: usize) -> Self {
584		Self {
585			data,
586			pagination: Some(PaginationInfo { offset, limit, total }),
587			cursor_pagination: None,
588			time: Timestamp::now(),
589			req_id: None,
590		}
591	}
592
593	/// Create a response with cursor-based pagination
594	pub fn with_cursor_pagination(data: T, next_cursor: Option<String>, has_more: bool) -> Self {
595		Self {
596			data,
597			pagination: None,
598			cursor_pagination: Some(CursorPaginationInfo { next_cursor, has_more }),
599			time: Timestamp::now(),
600			req_id: None,
601		}
602	}
603
604	/// Add request ID to response
605	pub fn with_req_id(mut self, req_id: String) -> Self {
606		self.req_id = Some(req_id);
607		self
608	}
609}
610
611/// Error response format
612#[derive(Debug, Serialize, Deserialize)]
613#[serde(rename_all = "camelCase")]
614pub struct ErrorResponse {
615	pub error: ErrorDetails,
616}
617
618/// Error details with structured code and message
619#[derive(Debug, Serialize, Deserialize)]
620#[serde(rename_all = "camelCase")]
621pub struct ErrorDetails {
622	pub code: String,
623	pub message: String,
624	#[serde(skip_serializing_if = "Option::is_none")]
625	pub details: Option<serde_json::Value>,
626}
627
628impl ErrorResponse {
629	/// Create a new error response with code and message
630	pub fn new(code: String, message: String) -> Self {
631		Self { error: ErrorDetails { code, message, details: None } }
632	}
633
634	/// Add additional details to error
635	pub fn with_details(mut self, details: serde_json::Value) -> Self {
636		self.error.details = Some(details);
637		self
638	}
639}
640
641// ABAC Permission System Types
642//*****************************
643
644/// Access level enum for files
645#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
646#[serde(rename_all = "lowercase")]
647pub enum AccessLevel {
648	None,
649	Read,
650	Comment,
651	Write,
652	Admin,
653}
654
655impl AccessLevel {
656	pub fn as_str(&self) -> &'static str {
657		match self {
658			Self::None => "none",
659			Self::Read => "read",
660			Self::Comment => "comment",
661			Self::Write => "write",
662			Self::Admin => "admin",
663		}
664	}
665
666	/// Return the lesser of two access levels.
667	/// Ordering: None < Read < Comment < Write < Admin
668	pub fn min(self, other: Self) -> Self {
669		match (self, other) {
670			(Self::None, _) | (_, Self::None) => Self::None,
671			(Self::Read, _) | (_, Self::Read) => Self::Read,
672			(Self::Comment, _) | (_, Self::Comment) => Self::Comment,
673			(Self::Write, _) | (_, Self::Write) => Self::Write,
674			(Self::Admin, Self::Admin) => Self::Admin,
675		}
676	}
677
678	/// Return the greater of two access levels.
679	/// Ordering: None < Read < Comment < Write < Admin
680	pub fn max(self, other: Self) -> Self {
681		match (self, other) {
682			(Self::Admin, _) | (_, Self::Admin) => Self::Admin,
683			(Self::Write, _) | (_, Self::Write) => Self::Write,
684			(Self::Comment, _) | (_, Self::Comment) => Self::Comment,
685			(Self::Read, _) | (_, Self::Read) => Self::Read,
686			(Self::None, Self::None) => Self::None,
687		}
688	}
689
690	/// Convert a share permission char ('R', 'C', 'W', 'A') to an access level.
691	/// 'A' (admin) maps to Write because scoped tokens don't carry admin privileges;
692	/// admin access is only resolved from direct ownership, not from share links.
693	/// Unknown chars default to Read.
694	pub fn from_perm_char(c: char) -> Self {
695		match c {
696			'W' | 'A' => Self::Write,
697			'C' => Self::Comment,
698			_ => Self::Read,
699		}
700	}
701}
702
703/// Token scope for scoped access tokens (e.g., share links)
704///
705/// Format in JWT: "file:{file_id}:{R|C|W}"
706/// This enum provides type-safe parsing instead of manual string splitting.
707#[derive(Debug, Clone, PartialEq, Eq)]
708pub enum TokenScope {
709	/// File-scoped access with specific access level
710	File { file_id: String, access: AccessLevel },
711	/// APKG publish scope — restricts to package upload and APKG action creation
712	ApkgPublish,
713}
714
715impl TokenScope {
716	/// Parse a scope string into a typed TokenScope
717	///
718	/// Supported formats:
719	/// - "file:{file_id}:R" -> File scope with Read access
720	/// - "file:{file_id}:C" -> File scope with Comment access
721	/// - "file:{file_id}:W" -> File scope with Write access
722	pub fn parse(s: &str) -> Option<Self> {
723		if s == "apkg:publish" {
724			return Some(Self::ApkgPublish);
725		}
726		let parts: Vec<&str> = s.split(':').collect();
727		if parts.len() == 3 && parts[0] == "file" {
728			let access = match parts[2] {
729				"W" => AccessLevel::Write,
730				"C" => AccessLevel::Comment,
731				_ => AccessLevel::Read, // "R" or any other value defaults to Read
732			};
733			return Some(Self::File { file_id: parts[1].to_string(), access });
734		}
735		None
736	}
737
738	/// Get file ID if this is a file scope
739	pub fn file_id(&self) -> Option<&str> {
740		match self {
741			Self::File { file_id, .. } => Some(file_id),
742			Self::ApkgPublish => None,
743		}
744	}
745
746	/// Get access level if this is a file scope
747	pub fn file_access(&self) -> Option<AccessLevel> {
748		match self {
749			Self::File { access, .. } => Some(*access),
750			Self::ApkgPublish => None,
751		}
752	}
753
754	/// Check if scope matches a specific file
755	pub fn matches_file(&self, target_file_id: &str) -> bool {
756		match self {
757			Self::File { file_id, .. } => file_id == target_file_id,
758			Self::ApkgPublish => false,
759		}
760	}
761}
762
763/// Profile attributes for ABAC
764#[derive(Debug, Clone)]
765pub struct ProfileAttrs {
766	pub id_tag: Box<str>,
767	pub profile_type: Box<str>,
768	pub tenant_tag: Box<str>,
769	pub roles: Vec<Box<str>>,
770	pub status: Box<str>,
771	pub following: bool,
772	pub connected: bool,
773	pub visibility: Box<str>,
774}
775
776impl AttrSet for ProfileAttrs {
777	fn get(&self, key: &str) -> Option<&str> {
778		match key {
779			"id_tag" => Some(&self.id_tag),
780			"profile_type" => Some(&self.profile_type),
781			"tenant_tag" | "owner_id_tag" => Some(&self.tenant_tag),
782			"status" => Some(&self.status),
783			"following" => Some(if self.following { "true" } else { "false" }),
784			"connected" => Some(if self.connected { "true" } else { "false" }),
785			"visibility" => Some(&self.visibility),
786			_ => None,
787		}
788	}
789
790	fn get_list(&self, key: &str) -> Option<Vec<&str>> {
791		match key {
792			"roles" => Some(self.roles.iter().map(AsRef::as_ref).collect()),
793			_ => None,
794		}
795	}
796}
797
798/// Action attributes for ABAC
799#[derive(Debug, Clone)]
800pub struct ActionAttrs {
801	pub typ: Box<str>,
802	pub sub_typ: Option<Box<str>>,
803	/// The tenant/instance where this action is stored (NOT the creator - see issuer_id_tag)
804	pub tenant_id_tag: Box<str>,
805	/// The original creator/sender of the action
806	pub issuer_id_tag: Box<str>,
807	pub parent_id: Option<Box<str>>,
808	pub root_id: Option<Box<str>>,
809	pub audience_tag: Vec<Box<str>>,
810	pub tags: Vec<Box<str>>,
811	pub visibility: Box<str>,
812	/// Whether the subject follows the action issuer
813	pub following: bool,
814	/// Whether the subject is connected (mutual) with the action issuer
815	pub connected: bool,
816}
817
818impl AttrSet for ActionAttrs {
819	fn get(&self, key: &str) -> Option<&str> {
820		match key {
821			"type" => Some(&self.typ),
822			"sub_type" => self.sub_typ.as_deref(),
823			// Support both old and new names for backward compat with ABAC rules
824			"tenant_id_tag" | "owner_id_tag" => Some(&self.tenant_id_tag),
825			"issuer_id_tag" => Some(&self.issuer_id_tag),
826			"parent_id" => self.parent_id.as_deref(),
827			"root_id" => self.root_id.as_deref(),
828			"visibility" => Some(&self.visibility),
829			"following" => Some(if self.following { "true" } else { "false" }),
830			"connected" => Some(if self.connected { "true" } else { "false" }),
831			_ => None,
832		}
833	}
834
835	fn get_list(&self, key: &str) -> Option<Vec<&str>> {
836		match key {
837			"audience_tag" => Some(self.audience_tag.iter().map(AsRef::as_ref).collect()),
838			"tags" => Some(self.tags.iter().map(AsRef::as_ref).collect()),
839			_ => None,
840		}
841	}
842}
843
844/// File attributes for ABAC
845#[derive(Debug, Clone)]
846pub struct FileAttrs {
847	pub file_id: Box<str>,
848	pub owner_id_tag: Box<str>,
849	pub mime_type: Box<str>,
850	pub tags: Vec<Box<str>>,
851	pub visibility: Box<str>,
852	pub access_level: AccessLevel,
853	/// Whether the subject follows the file owner
854	pub following: bool,
855	/// Whether the subject is connected (mutual) with the file owner
856	pub connected: bool,
857}
858
859impl AttrSet for FileAttrs {
860	fn get(&self, key: &str) -> Option<&str> {
861		match key {
862			"file_id" => Some(&self.file_id),
863			"owner_id_tag" => Some(&self.owner_id_tag),
864			"mime_type" => Some(&self.mime_type),
865			"visibility" => Some(&self.visibility),
866			"access_level" => Some(self.access_level.as_str()),
867			"following" => Some(if self.following { "true" } else { "false" }),
868			"connected" => Some(if self.connected { "true" } else { "false" }),
869			_ => None,
870		}
871	}
872
873	fn get_list(&self, key: &str) -> Option<Vec<&str>> {
874		match key {
875			"tags" => Some(self.tags.iter().map(AsRef::as_ref).collect()),
876			_ => None,
877		}
878	}
879}
880
881/// Subject attributes for ABAC (CREATE operations)
882///
883/// Used to evaluate collection-level permissions for operations
884/// that don't yet have a specific object (like file upload, post creation).
885#[derive(Debug, Clone)]
886pub struct SubjectAttrs {
887	pub id_tag: Box<str>,
888	pub roles: Vec<Box<str>>,
889	pub tier: Box<str>,                  // "free", "standard", "premium"
890	pub quota_remaining_bytes: Box<str>, // in bytes, as string for ABAC
891	pub rate_limit_remaining: Box<str>,  // per hour, as string for ABAC
892	pub banned: bool,
893	pub email_verified: bool,
894}
895
896impl AttrSet for SubjectAttrs {
897	fn get(&self, key: &str) -> Option<&str> {
898		match key {
899			"id_tag" => Some(&self.id_tag),
900			"tier" => Some(&self.tier),
901			"quota_remaining" | "quota_remaining_bytes" => Some(&self.quota_remaining_bytes),
902			"rate_limit_remaining" => Some(&self.rate_limit_remaining),
903			"banned" => Some(if self.banned { "true" } else { "false" }),
904			"email_verified" => Some(if self.email_verified { "true" } else { "false" }),
905			_ => None,
906		}
907	}
908
909	fn get_list(&self, key: &str) -> Option<Vec<&str>> {
910		match key {
911			"roles" => Some(self.roles.iter().map(AsRef::as_ref).collect()),
912			_ => None,
913		}
914	}
915}
916
917// vim: ts=4