aede_core/parental/
leases.rs

1//! Short durable reservations count overlapping transports once per account.
2use super::intervals::{Interval, duration, union};
3use super::storage::{Owner, Reservation, valid_lease, valid_owner};
4use super::{Media, ParentalStore, Status};
5use serde::Serialize;
6
7/// A bounded server-generated listening reservation, never an authentication token.
8#[derive(Debug, Clone, Serialize)]
9pub struct LeaseGrant {
10    /// Opaque identifier supplied by the trusted server's secure generator.
11    pub lease_id: String,
12    /// Policy generation used for this grant.
13    pub revision: u64,
14    /// Exclusive expiry in Unix seconds.
15    pub expires_at: u64,
16    /// Granted duration, between one and ten seconds.
17    pub granted_seconds: u32,
18}
19fn milliseconds(now: u64) -> Result<u64, String> {
20    now.checked_mul(1000)
21        .ok_or_else(|| "invalid parental clock".into())
22}
23fn category(policy: &super::Policy, media: &Media) -> Result<Option<&'static str>, String> {
24    match media {
25        Media::Radio if !policy.radio_allowed => Ok(Some("radio_blocked")),
26        Media::Podcast { .. } if !policy.podcasts_allowed => Ok(Some("podcast_blocked")),
27        Media::Podcast { feed } => {
28            let feed = super::canonical_feed(feed)?;
29            Ok(policy
30                .blocked_podcasts
31                .iter()
32                .any(|blocked| super::canonical_feed(blocked).is_ok_and(|value| value == feed))
33                .then_some("podcast_blocked"))
34        }
35        Media::Music { track: Some(token) } => {
36            let reference = crate::user::EntityRef::parse_token(token)
37                .ok_or("invalid parental audio reference")?;
38            if reference.kind != crate::model::EntityKind::Track
39                || reference.key.is_empty()
40                || token.len() > 16_384
41                || token.chars().any(char::is_control)
42            {
43                return Err("invalid parental audio reference".into());
44            }
45            Ok(None)
46        }
47        _ => Ok(None),
48    }
49}
50impl ParentalStore {
51    /// Recheck current category and clock windows without spending an existing grant twice.
52    pub fn check_schedule(&self, owner: &str, now: u64, media: &Media) -> Result<(), String> {
53        if !valid_owner(owner) {
54            return Err("invalid parental owner".into());
55        }
56        let fallback = Owner::default();
57        let row = self.owners.get(owner).unwrap_or(&fallback);
58        if !row.policy.enabled {
59            return Ok(());
60        }
61        if let Some(reason) = category(&row.policy, media)? {
62            return Err(reason.into());
63        }
64        if !super::schedule::clock(&row.policy, now)?.scheduled {
65            return Err("outside_schedule".into());
66        }
67        Ok(())
68    }
69
70    /// Inspect only one owner's policy and reserved/settled local calendar budgets.
71    pub fn status(&self, owner: &str, now: u64, media: &Media) -> Result<Status, String> {
72        if !valid_owner(owner) {
73            return Err("invalid parental owner".into());
74        }
75        let fallback = Owner::default();
76        let row = self.owners.get(owner).unwrap_or(&fallback);
77        let clock = super::schedule::clock(&row.policy, now)?;
78        let intervals = row.intervals();
79        let day_used = duration(
80            &intervals,
81            milliseconds(clock.day_start)?,
82            milliseconds(clock.day_end)?,
83        );
84        let week_used = duration(
85            &intervals,
86            milliseconds(clock.week_start)?,
87            milliseconds(clock.week_end)?,
88        );
89        let day_remaining = row.policy.days[clock.day].limit_seconds.map(|limit| {
90            u64::from(limit)
91                .saturating_mul(1000)
92                .saturating_sub(day_used)
93        });
94        let week_remaining = row.policy.week_limit_seconds.map(|limit| {
95            u64::from(limit)
96                .saturating_mul(1000)
97                .saturating_sub(week_used)
98        });
99        let reason = if !row.policy.enabled {
100            None
101        } else if let Some(reason) = category(&row.policy, media)? {
102            Some(reason)
103        } else if !clock.scheduled {
104            Some("outside_schedule")
105        } else if day_remaining == Some(0) {
106            Some("daily_limit")
107        } else if week_remaining == Some(0) {
108            Some("weekly_limit")
109        } else {
110            None
111        };
112        let remaining = match (day_remaining, week_remaining) {
113            (Some(a), Some(b)) => Some(a.min(b)),
114            (a, b) => a.or(b),
115        };
116        Ok(Status {
117            owner: owner.into(),
118            revision: row.revision,
119            policy: row.policy.clone(),
120            day_used_seconds: day_used.div_ceil(1000),
121            week_used_seconds: week_used.div_ceil(1000),
122            remaining_seconds: if row.policy.enabled {
123                remaining.map(|value| value / 1000)
124            } else {
125                None
126            },
127            allowed: reason.is_none(),
128            reason: reason.map(str::to_owned),
129            next_change_at: row.policy.enabled.then_some(clock.next),
130        })
131    }
132    /// Reserve at most ten seconds before audio is admitted. The caller saves before granting.
133    /// Overlapping transports share temporal coverage rather than spending their duration twice.
134    pub fn reserve(
135        &mut self,
136        owner: &str,
137        lease_id: &str,
138        media: Media,
139        requested_seconds: u32,
140        now: u64,
141    ) -> Result<LeaseGrant, String> {
142        if !valid_owner(owner) || !valid_lease(lease_id) || !(1..=10).contains(&requested_seconds) {
143            return Err("invalid parental listening reservation".into());
144        }
145        let mut candidate = self.clone();
146        let row = candidate.owners.entry(owner.into()).or_default();
147        let now_ms = milliseconds(now)?;
148        row.prune(now_ms);
149        if row.reservations.iter().any(|value| value.id == lease_id) {
150            return Err("parental listening leases are occupied".into());
151        }
152        row.make_room(now_ms);
153        if row.reservations.len() >= 8 {
154            return Err("parental listening leases are occupied".into());
155        }
156        let clock = super::schedule::clock(&row.policy, now)?;
157        if row.policy.enabled {
158            if let Some(reason) = category(&row.policy, &media)? {
159                return Err(reason.into());
160            }
161            if !clock.scheduled {
162                return Err("outside_schedule".into());
163            }
164        }
165        let available_seconds = if row.policy.enabled {
166            requested_seconds.min(u32::try_from(clock.next.saturating_sub(now)).unwrap_or(u32::MAX))
167        } else {
168            requested_seconds
169        };
170        let start_ms = milliseconds(now)?;
171        let held = row.intervals();
172        let day_used = duration(
173            &held,
174            milliseconds(clock.day_start)?,
175            milliseconds(clock.day_end)?,
176        );
177        let week_used = duration(
178            &held,
179            milliseconds(clock.week_start)?,
180            milliseconds(clock.week_end)?,
181        );
182        let mut granted = 0;
183        let mut refusal = "daily_limit";
184        for seconds in 1..=available_seconds {
185            let end_ms = start_ms
186                .checked_add(u64::from(seconds) * 1000)
187                .ok_or("invalid parental reservation clock")?;
188            // The candidate is inside the current day. Count only coverage
189            // not already reserved/settled by another voice; no repeated
190            // allocation or sorting of the retained interval ledger is needed.
191            let extra = u64::from(seconds) * 1000 - duration(&held, start_ms, end_ms);
192            if row.policy.enabled
193                && row.policy.days[clock.day]
194                    .limit_seconds
195                    .is_some_and(|limit| day_used + extra > u64::from(limit) * 1000)
196            {
197                refusal = "daily_limit";
198                break;
199            }
200            if row.policy.enabled
201                && row
202                    .policy
203                    .week_limit_seconds
204                    .is_some_and(|limit| week_used + extra > u64::from(limit) * 1000)
205            {
206                refusal = "weekly_limit";
207                break;
208            }
209            granted = seconds;
210        }
211        if granted == 0 {
212            return Err(refusal.into());
213        }
214        let grant = LeaseGrant {
215            lease_id: lease_id.into(),
216            revision: row.revision,
217            expires_at: now
218                .checked_add(u64::from(granted))
219                .ok_or("invalid parental expiry")?,
220            granted_seconds: granted,
221        };
222        row.reservations.push(Reservation {
223            id: lease_id.into(),
224            media,
225            interval: Interval {
226                start_ms,
227                end_ms: milliseconds(grant.expires_at)?,
228            },
229            consumed_ms: 0,
230        });
231        candidate.validate()?;
232        *self = candidate;
233        Ok(grant)
234    }
235    /// Confirm cumulative consumed milliseconds and close/refund only future activity.
236    /// Already elapsed time under an active grant is never refunded by a client's counter.
237    /// Expired reservations stay fully charged; a crash or late client cannot regain their budget.
238    pub fn settle(
239        &mut self,
240        owner: &str,
241        lease_id: &str,
242        consumed_ms: u64,
243        now: u64,
244        close: bool,
245    ) -> Result<(), String> {
246        let mut candidate = self.clone();
247        candidate.settle_inner(owner, lease_id, consumed_ms, now, close)?;
248        candidate.validate()?;
249        *self = candidate;
250        Ok(())
251    }
252    fn settle_inner(
253        &mut self,
254        owner: &str,
255        lease_id: &str,
256        consumed_ms: u64,
257        now: u64,
258        close: bool,
259    ) -> Result<(), String> {
260        let row = self
261            .owners
262            .get_mut(owner)
263            .ok_or("parental listening reservation is unavailable")?;
264        let index = row
265            .reservations
266            .iter()
267            .position(|value| value.id == lease_id)
268            .ok_or("parental listening reservation is unavailable")?;
269        let reservation = &mut row.reservations[index];
270        if consumed_ms < reservation.consumed_ms
271            || consumed_ms > reservation.interval.end_ms - reservation.interval.start_ms
272        {
273            return Err("invalid parental consumption counter".into());
274        }
275        let now_ms = milliseconds(now)?;
276        if now_ms < reservation.interval.start_ms {
277            return Err("parental clock moved backwards".into());
278        }
279        reservation.consumed_ms = consumed_ms;
280        if close {
281            let value = row.reservations.remove(index);
282            let end_ms = if now_ms > value.interval.end_ms {
283                value.interval.end_ms
284            } else {
285                // A client cannot refund time after enjoying an active grant
286                // merely by reporting zero consumption. Only future time is refundable.
287                value.interval.start_ms + consumed_ms.max(now_ms - value.interval.start_ms)
288            };
289            if end_ms > value.interval.start_ms {
290                row.settled.push(Interval {
291                    start_ms: value.interval.start_ms,
292                    end_ms,
293                });
294            }
295            row.settled = union(std::mem::take(&mut row.settled));
296        }
297        row.prune(now_ms);
298        self.validate()
299    }
300    /// Atomically settle a previous grant and reserve the next slice under the same identifier.
301    /// Consumption counters restart at zero for the returned grant.
302    pub fn renew(
303        &mut self,
304        owner: &str,
305        lease_id: &str,
306        consumed_ms: u64,
307        requested_seconds: u32,
308        now: u64,
309    ) -> Result<LeaseGrant, String> {
310        let media = self
311            .owners
312            .get(owner)
313            .and_then(|row| row.reservations.iter().find(|value| value.id == lease_id))
314            .map(|value| value.media.clone())
315            .ok_or("parental listening reservation is unavailable")?;
316        let mut candidate = self.clone();
317        candidate.settle(owner, lease_id, consumed_ms, now, true)?;
318        let grant = candidate.reserve(owner, lease_id, media, requested_seconds, now)?;
319        *self = candidate;
320        Ok(grant)
321    }
322}
323#[cfg(test)]
324#[path = "leases_tests.rs"]
325mod tests;