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