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/// Public app/web domain of a tenant (the cert `domain`). Used by clients on the
330/// API host (`cl-o.<idTag>`) to build links to the tenant's web UI (share links).
331#[derive(Debug, Clone, Serialize, Deserialize)]
332#[serde(rename_all = "camelCase")]
333pub struct AppDomainRes {
334	pub app_domain: String,
335}
336
337/// Profile patch for PATCH /me endpoint
338#[derive(Debug, Clone, Serialize, Deserialize)]
339#[serde(rename_all = "camelCase")]
340pub struct ProfilePatch {
341	#[serde(default)]
342	pub name: Patch<String>,
343	/// Extensible metadata fields (partial merge: existing keys preserved, null deletes)
344	#[serde(default)]
345	pub x: Option<HashMap<String, Option<String>>>,
346}
347
348/// Admin profile patch for PATCH /admin/profile/:idTag endpoint
349#[derive(Debug, Clone, Serialize, Deserialize)]
350#[serde(rename_all = "camelCase")]
351pub struct AdminProfilePatch {
352	// Basic profile fields
353	#[serde(default)]
354	pub name: Patch<String>,
355
356	// Administrative fields
357	#[serde(default)]
358	pub roles: Patch<Option<Vec<String>>>,
359	#[serde(default)]
360	pub status: Patch<crate::meta_adapter::ProfileStatus>,
361}
362
363/// Profile information response
364#[skip_serializing_none]
365#[derive(Debug, Clone, Serialize, Deserialize)]
366#[serde(rename_all = "camelCase")]
367pub struct ProfileInfo {
368	pub id_tag: String,
369	pub name: String,
370	#[serde(rename = "type")]
371	pub r#type: Option<String>,
372	pub profile_pic: Option<String>, // file_id
373	pub status: Option<crate::meta_adapter::ProfileStatus>,
374	pub connected: Option<bool>,
375	pub following: Option<bool>,
376	pub follower: Option<bool>,
377	/// Per-profile trust preference controlling proxy-token use on passive reads.
378	/// `"always"` = always authenticate, `"never"` = never, absent = ask.
379	pub trust: Option<crate::meta_adapter::ProfileTrust>,
380	pub roles: Option<Vec<String>>,
381	#[serde(
382		serialize_with = "serialize_timestamp_iso_opt",
383		skip_serializing_if = "Option::is_none"
384	)]
385	pub created_at: Option<Timestamp>,
386	/// Extensible metadata (profile sections, tab config, etc.)
387	pub x: Option<HashMap<String, String>>,
388}
389
390/// Request body for community profile creation
391#[derive(Debug, Clone, Deserialize)]
392#[serde(rename_all = "camelCase")]
393pub struct CreateCommunityRequest {
394	#[serde(rename = "type")]
395	pub typ: String, // "idp" or "domain" - identity type
396	pub name: Option<String>,
397	pub profile_pic: Option<String>,
398	pub app_domain: Option<String>, // For domain type
399	pub invite_ref: Option<String>, // Invite ref code for community creation
400}
401
402/// Response for community profile creation
403#[skip_serializing_none]
404#[derive(Debug, Clone, Serialize)]
405#[serde(rename_all = "camelCase")]
406pub struct CommunityProfileResponse {
407	pub id_tag: String,
408	pub name: String,
409	#[serde(rename = "type")]
410	pub r#type: String,
411	pub profile_pic: Option<String>,
412	#[serde(serialize_with = "serialize_timestamp_iso")]
413	pub created_at: Timestamp,
414	/// Initial value of the new community tenant's `ui.onboarding` setting.
415	/// `Some("verify-idp")` for an IDP-typed community whose IDP identity is
416	/// still pending; `None` for domain-typed (or already-active) communities.
417	/// The frontend uses this to decide whether to mark the community as
418	/// pending in the sidebar and show the activation banner.
419	pub onboarding: Option<String>,
420}
421
422// Phase 2: Action Management & File Integration
423//***********************************************
424
425/// Action creation request
426#[derive(Debug, Clone, Serialize, Deserialize)]
427#[serde(rename_all = "camelCase")]
428pub struct CreateActionRequest {
429	#[serde(rename = "type")]
430	pub r#type: String, // "Create", "Update", etc
431	pub sub_type: Option<String>, // "Note", "Image", etc
432	pub parent_id: Option<String>,
433	// Note: root_id is auto-populated from parent chain, not specified by clients
434	pub content: String,
435	pub attachments: Option<Vec<String>>, // file_ids
436	pub audience: Option<Vec<String>>,
437}
438
439/// Action response (API layer)
440#[skip_serializing_none]
441#[derive(Debug, Clone, Serialize, Deserialize)]
442#[serde(rename_all = "camelCase")]
443pub struct ActionResponse {
444	pub action_id: String,
445	pub action_token: String,
446	#[serde(rename = "type")]
447	pub r#type: String,
448	pub sub_type: Option<String>,
449	pub parent_id: Option<String>,
450	pub root_id: Option<String>,
451	pub content: String,
452	pub attachments: Vec<String>,
453	pub issuer_tag: String,
454	#[serde(serialize_with = "serialize_timestamp_iso")]
455	pub created_at: Timestamp,
456}
457
458/// List actions query parameters
459#[derive(Debug, Clone, Default, Deserialize)]
460#[serde(rename_all = "camelCase")]
461pub struct ListActionsQuery {
462	#[serde(rename = "type")]
463	pub r#type: Option<String>,
464	pub parent_id: Option<String>,
465	pub offset: Option<usize>,
466	pub limit: Option<usize>,
467}
468
469/// File upload response
470#[derive(Debug, Clone, Serialize, Deserialize)]
471#[serde(rename_all = "camelCase")]
472pub struct FileUploadResponse {
473	pub file_id: String,
474	pub descriptor: String,
475	pub variants: Vec<FileVariantInfo>,
476}
477
478/// File variant information
479#[derive(Debug, Clone, Serialize, Deserialize)]
480#[serde(rename_all = "camelCase")]
481pub struct FileVariantInfo {
482	pub variant_id: String,
483	pub format: String,
484	pub size: u64,
485	pub resolution: Option<(u32, u32)>,
486}
487
488/// Tag information with optional usage count
489#[derive(Debug, Clone, Serialize, Deserialize)]
490pub struct TagInfo {
491	pub tag: String,
492	#[serde(skip_serializing_if = "Option::is_none")]
493	pub count: Option<u32>,
494}
495
496// Phase 1: API Response Envelope & Error Types
497//***********************************************
498
499/// Pagination information for list responses (offset-based - deprecated)
500#[derive(Debug, Clone, Serialize, Deserialize)]
501#[serde(rename_all = "camelCase")]
502pub struct PaginationInfo {
503	pub offset: usize,
504	pub limit: usize,
505	pub total: usize,
506}
507
508/// Cursor-based pagination information for list responses
509///
510/// Provides stable pagination that handles data changes between requests.
511/// The cursor is an opaque base64-encoded JSON containing sort field, value, and last item ID.
512#[derive(Debug, Clone, Serialize, Deserialize)]
513#[serde(rename_all = "camelCase")]
514pub struct CursorPaginationInfo {
515	/// Opaque cursor for fetching next page (None if no more results)
516	pub next_cursor: Option<String>,
517	/// Whether more results are available
518	pub has_more: bool,
519}
520
521/// Cursor data structure (encoded as base64 JSON in API)
522#[derive(Debug, Clone, Serialize, Deserialize)]
523pub struct CursorData {
524	/// Sort field: "created", "modified", "recent", "name"
525	pub s: String,
526	/// Sort value (timestamp as i64 or string for name)
527	pub v: serde_json::Value,
528	/// Last item's external ID (file_id or action_id)
529	pub id: String,
530}
531
532impl CursorData {
533	/// Create a new cursor from sort field, value, and item ID
534	pub fn new(sort_field: &str, sort_value: serde_json::Value, item_id: &str) -> Self {
535		Self { s: sort_field.to_string(), v: sort_value, id: item_id.to_string() }
536	}
537
538	/// Encode cursor to base64 string
539	pub fn encode(&self) -> String {
540		use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
541		let json = serde_json::to_string(self).unwrap_or_default();
542		URL_SAFE_NO_PAD.encode(json.as_bytes())
543	}
544
545	/// Decode cursor from base64 string
546	pub fn decode(cursor: &str) -> Option<Self> {
547		use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
548		let bytes = URL_SAFE_NO_PAD.decode(cursor).ok()?;
549		let json = String::from_utf8(bytes).ok()?;
550		serde_json::from_str(&json).ok()
551	}
552
553	/// Get sort value as i64 timestamp (for date fields)
554	pub fn timestamp(&self) -> Option<i64> {
555		self.v.as_i64()
556	}
557
558	/// Get sort value as string (for name field)
559	pub fn string_value(&self) -> Option<&str> {
560		self.v.as_str()
561	}
562}
563
564/// Success response envelope for single objects
565#[derive(Debug, Serialize, Deserialize)]
566#[serde(rename_all = "camelCase")]
567pub struct ApiResponse<T> {
568	pub data: T,
569	#[serde(skip_serializing_if = "Option::is_none")]
570	pub pagination: Option<PaginationInfo>,
571	#[serde(skip_serializing_if = "Option::is_none")]
572	pub cursor_pagination: Option<CursorPaginationInfo>,
573	#[serde(serialize_with = "serialize_timestamp_iso")]
574	pub time: Timestamp,
575	#[serde(skip_serializing_if = "Option::is_none")]
576	pub req_id: Option<String>,
577}
578
579impl<T> ApiResponse<T> {
580	/// Create a new response with data and current time
581	pub fn new(data: T) -> Self {
582		Self {
583			data,
584			pagination: None,
585			cursor_pagination: None,
586			time: Timestamp::now(),
587			req_id: None,
588		}
589	}
590
591	/// Create a response with offset-based pagination info (deprecated)
592	pub fn with_pagination(data: T, offset: usize, limit: usize, total: usize) -> Self {
593		Self {
594			data,
595			pagination: Some(PaginationInfo { offset, limit, total }),
596			cursor_pagination: None,
597			time: Timestamp::now(),
598			req_id: None,
599		}
600	}
601
602	/// Create a response with cursor-based pagination
603	pub fn with_cursor_pagination(data: T, next_cursor: Option<String>, has_more: bool) -> Self {
604		Self {
605			data,
606			pagination: None,
607			cursor_pagination: Some(CursorPaginationInfo { next_cursor, has_more }),
608			time: Timestamp::now(),
609			req_id: None,
610		}
611	}
612
613	/// Add request ID to response
614	pub fn with_req_id(mut self, req_id: String) -> Self {
615		self.req_id = Some(req_id);
616		self
617	}
618}
619
620/// Error response format
621#[derive(Debug, Serialize, Deserialize)]
622#[serde(rename_all = "camelCase")]
623pub struct ErrorResponse {
624	pub error: ErrorDetails,
625}
626
627/// Error details with structured code and message
628#[derive(Debug, Serialize, Deserialize)]
629#[serde(rename_all = "camelCase")]
630pub struct ErrorDetails {
631	pub code: String,
632	pub message: String,
633	#[serde(skip_serializing_if = "Option::is_none")]
634	pub details: Option<serde_json::Value>,
635}
636
637impl ErrorResponse {
638	/// Create a new error response with code and message
639	pub fn new(code: String, message: String) -> Self {
640		Self { error: ErrorDetails { code, message, details: None } }
641	}
642
643	/// Add additional details to error
644	pub fn with_details(mut self, details: serde_json::Value) -> Self {
645		self.error.details = Some(details);
646		self
647	}
648}
649
650// ABAC Permission System Types
651//*****************************
652
653/// Access level enum for files
654#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
655#[serde(rename_all = "lowercase")]
656pub enum AccessLevel {
657	None,
658	Read,
659	Comment,
660	Write,
661	Admin,
662}
663
664impl AccessLevel {
665	pub fn as_str(&self) -> &'static str {
666		match self {
667			Self::None => "none",
668			Self::Read => "read",
669			Self::Comment => "comment",
670			Self::Write => "write",
671			Self::Admin => "admin",
672		}
673	}
674
675	/// Return the lesser of two access levels.
676	/// Ordering: None < Read < Comment < Write < Admin
677	pub fn min(self, other: Self) -> Self {
678		match (self, other) {
679			(Self::None, _) | (_, Self::None) => Self::None,
680			(Self::Read, _) | (_, Self::Read) => Self::Read,
681			(Self::Comment, _) | (_, Self::Comment) => Self::Comment,
682			(Self::Write, _) | (_, Self::Write) => Self::Write,
683			(Self::Admin, Self::Admin) => Self::Admin,
684		}
685	}
686
687	/// Return the greater of two access levels.
688	/// Ordering: None < Read < Comment < Write < Admin
689	pub fn max(self, other: Self) -> Self {
690		match (self, other) {
691			(Self::Admin, _) | (_, Self::Admin) => Self::Admin,
692			(Self::Write, _) | (_, Self::Write) => Self::Write,
693			(Self::Comment, _) | (_, Self::Comment) => Self::Comment,
694			(Self::Read, _) | (_, Self::Read) => Self::Read,
695			(Self::None, Self::None) => Self::None,
696		}
697	}
698
699	/// Convert a share permission char ('R', 'C', 'W', 'A') to an access level.
700	/// 'A' (admin) maps to Write because scoped tokens don't carry admin privileges;
701	/// admin access is only resolved from direct ownership, not from share links.
702	/// Unknown chars default to Read.
703	pub fn from_perm_char(c: char) -> Self {
704		match c {
705			'W' | 'A' => Self::Write,
706			'C' => Self::Comment,
707			_ => Self::Read,
708		}
709	}
710}
711
712/// Token scope for scoped access tokens (e.g., share links)
713///
714/// Format in JWT: "file:{file_id}:{R|C|W}"
715/// This enum provides type-safe parsing instead of manual string splitting.
716#[derive(Debug, Clone, PartialEq, Eq)]
717pub enum TokenScope {
718	/// File-scoped access with specific access level
719	File { file_id: String, access: AccessLevel },
720	/// APKG publish scope — restricts to package upload and APKG action creation
721	ApkgPublish,
722}
723
724impl TokenScope {
725	/// Parse a scope string into a typed TokenScope
726	///
727	/// Supported formats:
728	/// - "file:{file_id}:R" -> File scope with Read access
729	/// - "file:{file_id}:C" -> File scope with Comment access
730	/// - "file:{file_id}:W" -> File scope with Write access
731	pub fn parse(s: &str) -> Option<Self> {
732		if s == "apkg:publish" {
733			return Some(Self::ApkgPublish);
734		}
735		let parts: Vec<&str> = s.split(':').collect();
736		if parts.len() == 3 && parts[0] == "file" {
737			let access = match parts[2] {
738				"W" => AccessLevel::Write,
739				"C" => AccessLevel::Comment,
740				_ => AccessLevel::Read, // "R" or any other value defaults to Read
741			};
742			return Some(Self::File { file_id: parts[1].to_string(), access });
743		}
744		None
745	}
746
747	/// Get file ID if this is a file scope
748	pub fn file_id(&self) -> Option<&str> {
749		match self {
750			Self::File { file_id, .. } => Some(file_id),
751			Self::ApkgPublish => None,
752		}
753	}
754
755	/// Get access level if this is a file scope
756	pub fn file_access(&self) -> Option<AccessLevel> {
757		match self {
758			Self::File { access, .. } => Some(*access),
759			Self::ApkgPublish => None,
760		}
761	}
762
763	/// Check if scope matches a specific file
764	pub fn matches_file(&self, target_file_id: &str) -> bool {
765		match self {
766			Self::File { file_id, .. } => file_id == target_file_id,
767			Self::ApkgPublish => false,
768		}
769	}
770}
771
772/// Profile attributes for ABAC
773#[derive(Debug, Clone)]
774pub struct ProfileAttrs {
775	pub id_tag: Box<str>,
776	pub profile_type: Box<str>,
777	pub tenant_tag: Box<str>,
778	pub roles: Vec<Box<str>>,
779	pub status: Box<str>,
780	pub following: bool,
781	pub connected: bool,
782	pub visibility: Box<str>,
783}
784
785impl AttrSet for ProfileAttrs {
786	fn get(&self, key: &str) -> Option<&str> {
787		match key {
788			"id_tag" => Some(&self.id_tag),
789			"profile_type" => Some(&self.profile_type),
790			"tenant_tag" | "owner_id_tag" => Some(&self.tenant_tag),
791			"status" => Some(&self.status),
792			"following" => Some(if self.following { "true" } else { "false" }),
793			"connected" => Some(if self.connected { "true" } else { "false" }),
794			"visibility" => Some(&self.visibility),
795			_ => None,
796		}
797	}
798
799	fn get_list(&self, key: &str) -> Option<Vec<&str>> {
800		match key {
801			"roles" => Some(self.roles.iter().map(AsRef::as_ref).collect()),
802			_ => None,
803		}
804	}
805}
806
807/// Action attributes for ABAC
808#[derive(Debug, Clone)]
809pub struct ActionAttrs {
810	pub typ: Box<str>,
811	pub sub_typ: Option<Box<str>>,
812	/// The tenant/instance where this action is stored (NOT the creator - see issuer_id_tag)
813	pub tenant_id_tag: Box<str>,
814	/// The original creator/sender of the action
815	pub issuer_id_tag: Box<str>,
816	pub parent_id: Option<Box<str>>,
817	pub root_id: Option<Box<str>>,
818	pub audience_tag: Vec<Box<str>>,
819	pub tags: Vec<Box<str>>,
820	pub visibility: Box<str>,
821	/// Whether the subject follows the action issuer
822	pub following: bool,
823	/// Whether the subject is connected (mutual) with the action issuer
824	pub connected: bool,
825}
826
827impl AttrSet for ActionAttrs {
828	fn get(&self, key: &str) -> Option<&str> {
829		match key {
830			"type" => Some(&self.typ),
831			"sub_type" => self.sub_typ.as_deref(),
832			// Support both old and new names for backward compat with ABAC rules
833			"tenant_id_tag" | "owner_id_tag" => Some(&self.tenant_id_tag),
834			"issuer_id_tag" => Some(&self.issuer_id_tag),
835			"parent_id" => self.parent_id.as_deref(),
836			"root_id" => self.root_id.as_deref(),
837			"visibility" => Some(&self.visibility),
838			"following" => Some(if self.following { "true" } else { "false" }),
839			"connected" => Some(if self.connected { "true" } else { "false" }),
840			_ => None,
841		}
842	}
843
844	fn get_list(&self, key: &str) -> Option<Vec<&str>> {
845		match key {
846			"audience_tag" => Some(self.audience_tag.iter().map(AsRef::as_ref).collect()),
847			"tags" => Some(self.tags.iter().map(AsRef::as_ref).collect()),
848			_ => None,
849		}
850	}
851}
852
853/// File attributes for ABAC
854#[derive(Debug, Clone)]
855pub struct FileAttrs {
856	pub file_id: Box<str>,
857	pub owner_id_tag: Box<str>,
858	pub mime_type: Box<str>,
859	pub tags: Vec<Box<str>>,
860	pub visibility: Box<str>,
861	pub access_level: AccessLevel,
862	/// Whether the subject follows the file owner
863	pub following: bool,
864	/// Whether the subject is connected (mutual) with the file owner
865	pub connected: bool,
866}
867
868impl AttrSet for FileAttrs {
869	fn get(&self, key: &str) -> Option<&str> {
870		match key {
871			"file_id" => Some(&self.file_id),
872			"owner_id_tag" => Some(&self.owner_id_tag),
873			"mime_type" => Some(&self.mime_type),
874			"visibility" => Some(&self.visibility),
875			"access_level" => Some(self.access_level.as_str()),
876			"following" => Some(if self.following { "true" } else { "false" }),
877			"connected" => Some(if self.connected { "true" } else { "false" }),
878			_ => None,
879		}
880	}
881
882	fn get_list(&self, key: &str) -> Option<Vec<&str>> {
883		match key {
884			"tags" => Some(self.tags.iter().map(AsRef::as_ref).collect()),
885			_ => None,
886		}
887	}
888}
889
890/// Subject attributes for ABAC (CREATE operations)
891///
892/// Used to evaluate collection-level permissions for operations
893/// that don't yet have a specific object (like file upload, post creation).
894#[derive(Debug, Clone)]
895pub struct SubjectAttrs {
896	pub id_tag: Box<str>,
897	pub roles: Vec<Box<str>>,
898	pub tier: Box<str>,                  // "free", "standard", "premium"
899	pub quota_remaining_bytes: Box<str>, // in bytes, as string for ABAC
900	pub rate_limit_remaining: Box<str>,  // per hour, as string for ABAC
901	pub banned: bool,
902	pub email_verified: bool,
903}
904
905impl AttrSet for SubjectAttrs {
906	fn get(&self, key: &str) -> Option<&str> {
907		match key {
908			"id_tag" => Some(&self.id_tag),
909			"tier" => Some(&self.tier),
910			"quota_remaining" | "quota_remaining_bytes" => Some(&self.quota_remaining_bytes),
911			"rate_limit_remaining" => Some(&self.rate_limit_remaining),
912			"banned" => Some(if self.banned { "true" } else { "false" }),
913			"email_verified" => Some(if self.email_verified { "true" } else { "false" }),
914			_ => None,
915		}
916	}
917
918	fn get_list(&self, key: &str) -> Option<Vec<&str>> {
919		match key {
920			"roles" => Some(self.roles.iter().map(AsRef::as_ref).collect()),
921			_ => None,
922		}
923	}
924}
925
926// vim: ts=4