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, Default, 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	/// Reader's feed read-watermark for this context (seeds `useReadMarker`).
387	/// ISO 8601 string (round-trips with `PUT /api/read-marker`'s `position`).
388	#[serde(
389		serialize_with = "serialize_timestamp_iso_opt",
390		skip_serializing_if = "Option::is_none"
391	)]
392	pub feed_read_at: Option<Timestamp>,
393	/// Reader's DM read-watermark for this peer (seeds `useReadMarker`).
394	/// ISO 8601 string (round-trips with `PUT /api/read-marker`'s `position`).
395	#[serde(
396		serialize_with = "serialize_timestamp_iso_opt",
397		skip_serializing_if = "Option::is_none"
398	)]
399	pub msg_read_at: Option<Timestamp>,
400	/// Composition control for the home feed (community profiles): `Some(true)` =
401	/// hidden from the merged home feed. Absent/`None` = shown (the default).
402	pub hidden_in_home: Option<bool>,
403	/// Extensible metadata (profile sections, tab config, etc.)
404	pub x: Option<HashMap<String, String>>,
405}
406
407/// Request body for community profile creation
408#[derive(Debug, Clone, Deserialize)]
409#[serde(rename_all = "camelCase")]
410pub struct CreateCommunityRequest {
411	#[serde(rename = "type")]
412	pub typ: String, // "idp" or "domain" - identity type
413	pub name: Option<String>,
414	pub profile_pic: Option<String>,
415	pub app_domain: Option<String>, // For domain type
416	pub invite_ref: Option<String>, // Invite ref code for community creation
417}
418
419/// Response for community profile creation
420#[skip_serializing_none]
421#[derive(Debug, Clone, Serialize)]
422#[serde(rename_all = "camelCase")]
423pub struct CommunityProfileResponse {
424	pub id_tag: String,
425	pub name: String,
426	#[serde(rename = "type")]
427	pub r#type: String,
428	pub profile_pic: Option<String>,
429	#[serde(serialize_with = "serialize_timestamp_iso")]
430	pub created_at: Timestamp,
431	/// Initial value of the new community tenant's `ui.onboarding` setting.
432	/// `Some("verify-idp")` for an IDP-typed community whose IDP identity is
433	/// still pending; `None` for domain-typed (or already-active) communities.
434	/// The frontend uses this to decide whether to mark the community as
435	/// pending in the sidebar and show the activation banner.
436	pub onboarding: Option<String>,
437}
438
439// Phase 2: Action Management & File Integration
440//***********************************************
441
442/// Action creation request
443#[derive(Debug, Clone, Serialize, Deserialize)]
444#[serde(rename_all = "camelCase")]
445pub struct CreateActionRequest {
446	#[serde(rename = "type")]
447	pub r#type: String, // "Create", "Update", etc
448	pub sub_type: Option<String>, // "Note", "Image", etc
449	pub parent_id: Option<String>,
450	// Note: root_id is auto-populated from parent chain, not specified by clients
451	pub content: String,
452	pub attachments: Option<Vec<String>>, // file_ids
453	pub audience: Option<Vec<String>>,
454}
455
456/// Action response (API layer)
457#[skip_serializing_none]
458#[derive(Debug, Clone, Serialize, Deserialize)]
459#[serde(rename_all = "camelCase")]
460pub struct ActionResponse {
461	pub action_id: String,
462	pub action_token: String,
463	#[serde(rename = "type")]
464	pub r#type: String,
465	pub sub_type: Option<String>,
466	pub parent_id: Option<String>,
467	pub root_id: Option<String>,
468	pub content: String,
469	pub attachments: Vec<String>,
470	pub issuer_tag: String,
471	#[serde(serialize_with = "serialize_timestamp_iso")]
472	pub created_at: Timestamp,
473}
474
475/// List actions query parameters
476#[derive(Debug, Clone, Default, Deserialize)]
477#[serde(rename_all = "camelCase")]
478pub struct ListActionsQuery {
479	#[serde(rename = "type")]
480	pub r#type: Option<String>,
481	pub parent_id: Option<String>,
482	pub offset: Option<usize>,
483	pub limit: Option<usize>,
484}
485
486/// File upload response
487#[derive(Debug, Clone, Serialize, Deserialize)]
488#[serde(rename_all = "camelCase")]
489pub struct FileUploadResponse {
490	pub file_id: String,
491	pub descriptor: String,
492	pub variants: Vec<FileVariantInfo>,
493}
494
495/// File variant information
496#[derive(Debug, Clone, Serialize, Deserialize)]
497#[serde(rename_all = "camelCase")]
498pub struct FileVariantInfo {
499	pub variant_id: String,
500	pub format: String,
501	pub size: u64,
502	pub resolution: Option<(u32, u32)>,
503}
504
505/// Tag information with optional usage count
506#[derive(Debug, Clone, Serialize, Deserialize)]
507pub struct TagInfo {
508	pub tag: String,
509	#[serde(skip_serializing_if = "Option::is_none")]
510	pub count: Option<u32>,
511}
512
513// Phase 1: API Response Envelope & Error Types
514//***********************************************
515
516/// Pagination information for list responses (offset-based - deprecated)
517#[derive(Debug, Clone, Serialize, Deserialize)]
518#[serde(rename_all = "camelCase")]
519pub struct PaginationInfo {
520	pub offset: usize,
521	pub limit: usize,
522	pub total: usize,
523}
524
525/// Cursor-based pagination information for list responses
526///
527/// Provides stable pagination that handles data changes between requests.
528/// The cursor is an opaque base64-encoded JSON containing sort field, value, and last item ID.
529#[derive(Debug, Clone, Serialize, Deserialize)]
530#[serde(rename_all = "camelCase")]
531pub struct CursorPaginationInfo {
532	/// Opaque cursor for fetching next page (None if no more results)
533	pub next_cursor: Option<String>,
534	/// Whether more results are available
535	pub has_more: bool,
536	/// Aggregate row count. `Some` only on a `count=true` response (which pairs it
537	/// with an empty `data` array); `None` on every non-count response.
538	#[serde(skip_serializing_if = "Option::is_none")]
539	pub count: Option<i64>,
540}
541
542/// Cursor data structure (encoded as base64 JSON in API)
543#[derive(Debug, Clone, Serialize, Deserialize)]
544pub struct CursorData {
545	/// Sort field: "created", "modified", "recent", "name"
546	pub s: String,
547	/// Sort value (timestamp as i64 or string for name)
548	pub v: serde_json::Value,
549	/// Last item's external ID (file_id or action_id)
550	pub id: String,
551}
552
553impl CursorData {
554	/// Create a new cursor from sort field, value, and item ID
555	pub fn new(sort_field: &str, sort_value: serde_json::Value, item_id: &str) -> Self {
556		Self { s: sort_field.to_string(), v: sort_value, id: item_id.to_string() }
557	}
558
559	/// Encode cursor to base64 string
560	pub fn encode(&self) -> String {
561		use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
562		let json = serde_json::to_string(self).unwrap_or_default();
563		URL_SAFE_NO_PAD.encode(json.as_bytes())
564	}
565
566	/// Decode cursor from base64 string
567	pub fn decode(cursor: &str) -> Option<Self> {
568		use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
569		let bytes = URL_SAFE_NO_PAD.decode(cursor).ok()?;
570		let json = String::from_utf8(bytes).ok()?;
571		serde_json::from_str(&json).ok()
572	}
573
574	/// Get sort value as i64 timestamp (for date fields)
575	pub fn timestamp(&self) -> Option<i64> {
576		self.v.as_i64()
577	}
578
579	/// Get sort value as string (for name field)
580	pub fn string_value(&self) -> Option<&str> {
581		self.v.as_str()
582	}
583}
584
585/// Success response envelope for single objects
586#[derive(Debug, Serialize, Deserialize)]
587#[serde(rename_all = "camelCase")]
588pub struct ApiResponse<T> {
589	pub data: T,
590	#[serde(skip_serializing_if = "Option::is_none")]
591	pub pagination: Option<PaginationInfo>,
592	#[serde(skip_serializing_if = "Option::is_none")]
593	pub cursor_pagination: Option<CursorPaginationInfo>,
594	#[serde(serialize_with = "serialize_timestamp_iso")]
595	pub time: Timestamp,
596	#[serde(skip_serializing_if = "Option::is_none")]
597	pub req_id: Option<String>,
598}
599
600impl<T> ApiResponse<T> {
601	/// Create a new response with data and current time
602	pub fn new(data: T) -> Self {
603		Self {
604			data,
605			pagination: None,
606			cursor_pagination: None,
607			time: Timestamp::now(),
608			req_id: None,
609		}
610	}
611
612	/// Create a response with offset-based pagination info (deprecated)
613	pub fn with_pagination(data: T, offset: usize, limit: usize, total: usize) -> Self {
614		Self {
615			data,
616			pagination: Some(PaginationInfo { offset, limit, total }),
617			cursor_pagination: None,
618			time: Timestamp::now(),
619			req_id: None,
620		}
621	}
622
623	/// Create a response with cursor-based pagination
624	pub fn with_cursor_pagination(data: T, next_cursor: Option<String>, has_more: bool) -> Self {
625		Self {
626			data,
627			pagination: None,
628			cursor_pagination: Some(CursorPaginationInfo { next_cursor, has_more, count: None }),
629			time: Timestamp::now(),
630			req_id: None,
631		}
632	}
633
634	/// Create an aggregate-count response: `data` is the empty default and the
635	/// count is surfaced only under `cursorPagination.count`. Backs the
636	/// `count=true` flag on `GET /actions`.
637	pub fn with_count(count: i64) -> Self
638	where
639		T: Default,
640	{
641		Self {
642			data: T::default(),
643			pagination: None,
644			cursor_pagination: Some(CursorPaginationInfo {
645				next_cursor: None,
646				has_more: false,
647				count: Some(count),
648			}),
649			time: Timestamp::now(),
650			req_id: None,
651		}
652	}
653
654	/// Add request ID to response
655	pub fn with_req_id(mut self, req_id: String) -> Self {
656		self.req_id = Some(req_id);
657		self
658	}
659}
660
661/// Error response format
662#[derive(Debug, Serialize, Deserialize)]
663#[serde(rename_all = "camelCase")]
664pub struct ErrorResponse {
665	pub error: ErrorDetails,
666}
667
668/// Error details with structured code and message
669#[derive(Debug, Serialize, Deserialize)]
670#[serde(rename_all = "camelCase")]
671pub struct ErrorDetails {
672	pub code: String,
673	pub message: String,
674	#[serde(skip_serializing_if = "Option::is_none")]
675	pub details: Option<serde_json::Value>,
676}
677
678impl ErrorResponse {
679	/// Create a new error response with code and message
680	pub fn new(code: String, message: String) -> Self {
681		Self { error: ErrorDetails { code, message, details: None } }
682	}
683
684	/// Add additional details to error
685	pub fn with_details(mut self, details: serde_json::Value) -> Self {
686		self.error.details = Some(details);
687		self
688	}
689}
690
691// ABAC Permission System Types
692//*****************************
693
694/// Access level enum for files
695///
696/// The variant order IS the ordering (None < Read < Comment < Write < Admin), so `Ord` lets
697/// callers cap one level by another — e.g. a share manager may not grant more than they hold.
698#[derive(
699	Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
700)]
701#[serde(rename_all = "lowercase")]
702pub enum AccessLevel {
703	None,
704	Read,
705	Comment,
706	Write,
707	Admin,
708}
709
710impl AccessLevel {
711	pub fn as_str(&self) -> &'static str {
712		match self {
713			Self::None => "none",
714			Self::Read => "read",
715			Self::Comment => "comment",
716			Self::Write => "write",
717			Self::Admin => "admin",
718		}
719	}
720
721	/// Parse the wire/attribute name produced by [`AccessLevel::as_str`], for call sites that
722	/// receive a level as an untyped string (the ABAC object attribute bag). Unknown names yield
723	/// `None` so a caller denies rather than guessing.
724	pub fn from_str_name(s: &str) -> Option<Self> {
725		match s {
726			"none" => Some(Self::None),
727			"read" => Some(Self::Read),
728			"comment" => Some(Self::Comment),
729			"write" => Some(Self::Write),
730			"admin" => Some(Self::Admin),
731			_ => Option::None,
732		}
733	}
734
735	/// Convert a permission char ('R', 'C', 'W', 'A') to an access level.
736	///
737	/// The single converter for the `permission CHAR(1)` vocabulary used by `share_entries`,
738	/// `file_user_data.access_level` and ref rows. `'A'` is a *distinct* level, not a synonym for
739	/// write: it additionally confers share management. Unknown chars fall back to `Read` —
740	/// fail-safe for a corrupt row, which the adapter logs.
741	pub fn from_perm_char(c: char) -> Self {
742		match c {
743			'A' => Self::Admin,
744			'W' => Self::Write,
745			'C' => Self::Comment,
746			_ => Self::Read,
747		}
748	}
749
750	/// Map an access level back to its single-char wire form.
751	///
752	/// Injective inverse of [`AccessLevel::from_perm_char`]. `None` has no char — the caller
753	/// should clear the stored value rather than write a stale `'R'`.
754	pub fn to_perm_char(self) -> Option<char> {
755		match self {
756			Self::None => Option::None,
757			Self::Read => Some('R'),
758			Self::Comment => Some('C'),
759			Self::Write => Some('W'),
760			Self::Admin => Some('A'),
761		}
762	}
763
764	/// Map an access level to the char used inside a *token scope* (`file:{id}:{R|C|W}`).
765	///
766	/// Deliberately lossy at the top: `Admin` caps to `'W'`. `share_access` refuses every scoped
767	/// caller, so admin inside a scope would be unusable, and emitting it risks a parser elsewhere
768	/// reading it as authority.
769	///
770	/// `None` has no char, mirroring [`AccessLevel::to_perm_char`]: a scope is a grant, so "no
771	/// access" must not silently become a read scope. Callers already hold `>= Read`, making the
772	/// `None` arm a compile-time reminder rather than a live path.
773	pub fn to_scope_char(self) -> Option<char> {
774		match self {
775			Self::Admin | Self::Write => Some('W'),
776			Self::Comment => Some('C'),
777			Self::Read => Some('R'),
778			Self::None => Option::None,
779		}
780	}
781
782	/// May read the resource.
783	pub fn can_read(self) -> bool {
784		self >= Self::Read
785	}
786
787	/// May comment on the resource.
788	pub fn can_comment(self) -> bool {
789		self >= Self::Comment
790	}
791
792	/// May modify the resource. True for `Admin` too — use ordering, never `== Write`.
793	pub fn can_write(self) -> bool {
794		self >= Self::Write
795	}
796
797	/// May manage the resource's share set (grant, revoke, mint share links).
798	pub fn can_manage_shares(self) -> bool {
799		self == Self::Admin
800	}
801}
802
803/// Token scope for scoped access tokens (e.g., share links)
804///
805/// Format in JWT: "file:{file_id}:{R|C|W}"
806/// This enum provides type-safe parsing instead of manual string splitting.
807#[derive(Debug, Clone, PartialEq, Eq)]
808pub enum TokenScope {
809	/// File-scoped access with specific access level
810	File { file_id: String, access: AccessLevel },
811	/// APKG publish scope — restricts to package upload and APKG action creation
812	ApkgPublish,
813}
814
815impl TokenScope {
816	/// Parse a scope string into a typed TokenScope
817	///
818	/// Supported formats:
819	/// - "file:{file_id}:R" -> File scope with Read access
820	/// - "file:{file_id}:C" -> File scope with Comment access
821	/// - "file:{file_id}:W" -> File scope with Write access
822	pub fn parse(s: &str) -> Option<Self> {
823		if s == "apkg:publish" {
824			return Some(Self::ApkgPublish);
825		}
826		let parts: Vec<&str> = s.split(':').collect();
827		if parts.len() == 3 && parts[0] == "file" {
828			// Exhaustive on purpose: an unrecognized level char (including `'A'`, which
829			// `to_scope_char` never emits) makes the whole scope unparseable, which callers treat
830			// as "deny". A `Read` fallback would silently *widen* a malformed scope.
831			let access = match parts[2] {
832				"R" => AccessLevel::Read,
833				"C" => AccessLevel::Comment,
834				"W" => AccessLevel::Write,
835				_ => return None,
836			};
837			return Some(Self::File { file_id: parts[1].to_string(), access });
838		}
839		None
840	}
841
842	/// Get file ID if this is a file scope
843	pub fn file_id(&self) -> Option<&str> {
844		match self {
845			Self::File { file_id, .. } => Some(file_id),
846			Self::ApkgPublish => None,
847		}
848	}
849
850	/// Get access level if this is a file scope
851	pub fn file_access(&self) -> Option<AccessLevel> {
852		match self {
853			Self::File { access, .. } => Some(*access),
854			Self::ApkgPublish => None,
855		}
856	}
857
858	/// Check if scope matches a specific file
859	pub fn matches_file(&self, target_file_id: &str) -> bool {
860		match self {
861			Self::File { file_id, .. } => file_id == target_file_id,
862			Self::ApkgPublish => false,
863		}
864	}
865}
866
867/// Profile attributes for ABAC
868#[derive(Debug, Clone)]
869pub struct ProfileAttrs {
870	pub id_tag: Box<str>,
871	pub profile_type: Box<str>,
872	pub tenant_tag: Box<str>,
873	pub roles: Vec<Box<str>>,
874	pub status: Box<str>,
875	pub following: bool,
876	pub connected: bool,
877	pub visibility: Box<str>,
878}
879
880impl AttrSet for ProfileAttrs {
881	fn get(&self, key: &str) -> Option<&str> {
882		match key {
883			"id_tag" => Some(&self.id_tag),
884			"profile_type" => Some(&self.profile_type),
885			"tenant_tag" | "owner_id_tag" => Some(&self.tenant_tag),
886			"status" => Some(&self.status),
887			"following" => Some(if self.following { "true" } else { "false" }),
888			"connected" => Some(if self.connected { "true" } else { "false" }),
889			"visibility" => Some(&self.visibility),
890			_ => None,
891		}
892	}
893
894	fn get_list(&self, key: &str) -> Option<Vec<&str>> {
895		match key {
896			"roles" => Some(self.roles.iter().map(AsRef::as_ref).collect()),
897			_ => None,
898		}
899	}
900}
901
902/// Action attributes for ABAC
903#[derive(Debug, Clone)]
904pub struct ActionAttrs {
905	pub typ: Box<str>,
906	pub sub_typ: Option<Box<str>>,
907	/// The tenant/instance where this action is stored (NOT the creator - see issuer_id_tag)
908	pub tenant_id_tag: Box<str>,
909	/// The original creator/sender of the action
910	pub issuer_id_tag: Box<str>,
911	pub parent_id: Option<Box<str>>,
912	pub root_id: Option<Box<str>>,
913	pub audience_tag: Vec<Box<str>>,
914	pub tags: Vec<Box<str>>,
915	pub visibility: Box<str>,
916	/// Whether the subject follows the action issuer
917	pub following: bool,
918	/// Whether the subject is connected (mutual) with the action issuer
919	pub connected: bool,
920}
921
922impl AttrSet for ActionAttrs {
923	fn get(&self, key: &str) -> Option<&str> {
924		match key {
925			"type" => Some(&self.typ),
926			"sub_type" => self.sub_typ.as_deref(),
927			// Support both old and new names for backward compat with ABAC rules
928			"tenant_id_tag" | "owner_id_tag" => Some(&self.tenant_id_tag),
929			"issuer_id_tag" => Some(&self.issuer_id_tag),
930			"parent_id" => self.parent_id.as_deref(),
931			"root_id" => self.root_id.as_deref(),
932			"visibility" => Some(&self.visibility),
933			"following" => Some(if self.following { "true" } else { "false" }),
934			"connected" => Some(if self.connected { "true" } else { "false" }),
935			_ => None,
936		}
937	}
938
939	fn get_list(&self, key: &str) -> Option<Vec<&str>> {
940		match key {
941			"audience_tag" => Some(self.audience_tag.iter().map(AsRef::as_ref).collect()),
942			"tags" => Some(self.tags.iter().map(AsRef::as_ref).collect()),
943			_ => None,
944		}
945	}
946}
947
948/// File attributes for ABAC
949#[derive(Debug, Clone)]
950pub struct FileAttrs {
951	pub file_id: Box<str>,
952	pub owner_id_tag: Box<str>,
953	pub mime_type: Box<str>,
954	pub tags: Vec<Box<str>>,
955	pub visibility: Box<str>,
956	pub access_level: AccessLevel,
957	/// Whether the subject follows the file owner
958	pub following: bool,
959	/// Whether the subject is connected (mutual) with the file owner
960	pub connected: bool,
961}
962
963impl AttrSet for FileAttrs {
964	fn get(&self, key: &str) -> Option<&str> {
965		match key {
966			"file_id" => Some(&self.file_id),
967			"owner_id_tag" => Some(&self.owner_id_tag),
968			"mime_type" => Some(&self.mime_type),
969			"visibility" => Some(&self.visibility),
970			"access_level" => Some(self.access_level.as_str()),
971			"following" => Some(if self.following { "true" } else { "false" }),
972			"connected" => Some(if self.connected { "true" } else { "false" }),
973			_ => None,
974		}
975	}
976
977	fn get_list(&self, key: &str) -> Option<Vec<&str>> {
978		match key {
979			"tags" => Some(self.tags.iter().map(AsRef::as_ref).collect()),
980			_ => None,
981		}
982	}
983}
984
985/// Subject attributes for ABAC (CREATE operations)
986///
987/// Used to evaluate collection-level permissions for operations
988/// that don't yet have a specific object (like file upload, post creation).
989#[derive(Debug, Clone)]
990pub struct SubjectAttrs {
991	pub id_tag: Box<str>,
992	pub roles: Vec<Box<str>>,
993	pub tier: Box<str>,                  // "free", "standard", "premium"
994	pub quota_remaining_bytes: Box<str>, // in bytes, as string for ABAC
995	pub rate_limit_remaining: Box<str>,  // per hour, as string for ABAC
996	pub banned: bool,
997	pub email_verified: bool,
998}
999
1000impl AttrSet for SubjectAttrs {
1001	fn get(&self, key: &str) -> Option<&str> {
1002		match key {
1003			"id_tag" => Some(&self.id_tag),
1004			"tier" => Some(&self.tier),
1005			"quota_remaining" | "quota_remaining_bytes" => Some(&self.quota_remaining_bytes),
1006			"rate_limit_remaining" => Some(&self.rate_limit_remaining),
1007			"banned" => Some(if self.banned { "true" } else { "false" }),
1008			"email_verified" => Some(if self.email_verified { "true" } else { "false" }),
1009			_ => None,
1010		}
1011	}
1012
1013	fn get_list(&self, key: &str) -> Option<Vec<&str>> {
1014		match key {
1015			"roles" => Some(self.roles.iter().map(AsRef::as_ref).collect()),
1016			_ => None,
1017		}
1018	}
1019}
1020
1021#[cfg(test)]
1022mod access_level_tests {
1023	use super::{AccessLevel, TokenScope};
1024
1025	#[test]
1026	fn perm_char_keeps_admin_distinct() {
1027		assert_eq!(AccessLevel::from_perm_char('A'), AccessLevel::Admin);
1028		assert_eq!(AccessLevel::from_perm_char('W'), AccessLevel::Write);
1029		assert_eq!(AccessLevel::from_perm_char('C'), AccessLevel::Comment);
1030		assert_eq!(AccessLevel::from_perm_char('R'), AccessLevel::Read);
1031		// Corrupt rows fail safe to the weakest level.
1032		assert_eq!(AccessLevel::from_perm_char('?'), AccessLevel::Read);
1033	}
1034
1035	#[test]
1036	fn to_perm_char_is_the_injective_inverse() {
1037		for level in
1038			[AccessLevel::Read, AccessLevel::Comment, AccessLevel::Write, AccessLevel::Admin]
1039		{
1040			let c = level.to_perm_char().expect("every level above None has a char");
1041			assert_eq!(AccessLevel::from_perm_char(c), level);
1042		}
1043		// `None` has no char — the caller clears the stored value instead.
1044		assert_eq!(AccessLevel::None.to_perm_char(), None);
1045	}
1046
1047	#[test]
1048	fn str_name_round_trips_every_variant() {
1049		// The ABAC attribute bag carries the level as an untyped string, so this round trip is the
1050		// only thing keeping that gate's ordering in step with this enum.
1051		for level in [
1052			AccessLevel::None,
1053			AccessLevel::Read,
1054			AccessLevel::Comment,
1055			AccessLevel::Write,
1056			AccessLevel::Admin,
1057		] {
1058			assert_eq!(AccessLevel::from_str_name(level.as_str()), Some(level));
1059		}
1060		// Unknown names yield `None`, distinct from `Some(AccessLevel::None)` — a recognized
1061		// "no access" — so the caller denies rather than guessing.
1062		assert_eq!(AccessLevel::from_str_name("bogus"), None);
1063		assert_eq!(AccessLevel::from_str_name(""), None);
1064		// Case-sensitive: `as_str` only ever emits lowercase.
1065		assert_eq!(AccessLevel::from_str_name("Write"), None);
1066	}
1067
1068	#[test]
1069	fn scope_char_caps_admin_at_write() {
1070		assert_eq!(AccessLevel::Admin.to_scope_char(), Some('W'));
1071		assert_eq!(AccessLevel::Write.to_scope_char(), Some('W'));
1072		assert_eq!(AccessLevel::Comment.to_scope_char(), Some('C'));
1073		assert_eq!(AccessLevel::Read.to_scope_char(), Some('R'));
1074		// "No access" is not a read scope: it has no char at all, so a caller cannot accidentally
1075		// mint `file:{id}:R` out of a denial.
1076		assert_eq!(AccessLevel::None.to_scope_char(), None);
1077	}
1078
1079	#[test]
1080	fn predicates_follow_the_ordering() {
1081		assert!(AccessLevel::Admin.can_read());
1082		assert!(AccessLevel::Admin.can_comment());
1083		assert!(AccessLevel::Admin.can_write());
1084		assert!(AccessLevel::Admin.can_manage_shares());
1085
1086		assert!(AccessLevel::Write.can_write());
1087		assert!(!AccessLevel::Write.can_manage_shares());
1088		assert!(!AccessLevel::Comment.can_write());
1089		assert!(AccessLevel::Comment.can_comment());
1090		assert!(!AccessLevel::Read.can_comment());
1091		assert!(AccessLevel::Read.can_read());
1092		assert!(!AccessLevel::None.can_read());
1093	}
1094
1095	#[test]
1096	fn scope_parse_rejects_an_admin_level_char() {
1097		// `'A'` must not parse: callers treat an unparseable scope as deny, whereas a fallback
1098		// would widen it into a valid Read scope.
1099		assert_eq!(TokenScope::parse("file:f1~abc:A"), None);
1100		assert_eq!(TokenScope::parse("file:f1~abc:X"), None);
1101		assert_eq!(
1102			TokenScope::parse("file:f1~abc:W"),
1103			Some(TokenScope::File { file_id: "f1~abc".to_string(), access: AccessLevel::Write })
1104		);
1105		assert_eq!(
1106			TokenScope::parse("file:f1~abc:R"),
1107			Some(TokenScope::File { file_id: "f1~abc".to_string(), access: AccessLevel::Read })
1108		);
1109	}
1110}
1111
1112/// What a storage-file compaction gave back.
1113///
1114/// `bytes_before`/`bytes_after` are summed over every file the adapter rewrote;
1115/// `files` is how many it touched. All zero means the adapter does not support
1116/// compaction, which is the trait's default.
1117#[derive(Debug, Clone, Copy, Default)]
1118pub struct CompactReport {
1119	pub files: usize,
1120	pub bytes_before: u64,
1121	pub bytes_after: u64,
1122}
1123
1124// vim: ts=4