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