Skip to main content

cloudillo_types/
types.rs

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