Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

272 linesCodeBlameRaw
1//! Intake is independent of account closure, workspace ownership and billing.
2//! Staff assess shared content and retention; an event publish is not proof of erasure.
3use crate::{Identity, security::is_person};
4use g1t_contracts::identity::UserArgs;
5use g1t_contracts::time::rfc3339;
6use g1t_contracts::{FailureCode, Outcome, User, new_id};
7use g1t_kit::now_ms;
8use serde::{Deserialize, Serialize};
9use worker::Result;
10
11#[derive(Deserialize)]
12pub(crate) struct RequestArgs {
13 user: User,
14}
15#[derive(Deserialize)]
16pub(crate) struct ReviewArgs {
17 id: String,
18 staff: String,
19 status: String,
20 evidence: String,
21}
22#[derive(Deserialize)]
23pub(crate) struct IntakeArgs {
24 username: String,
25 received_at: String,
26 staff: String,
27 evidence: String,
28}
29#[derive(Deserialize, Serialize)]
30pub(crate) struct ErasureRequest {
31 id: String,
32 user_id: String,
33 username: String,
34 requested_at: String,
35 due_at: String,
36 status: String,
37 scope: String,
38 evidence: String,
39 updated_at: String,
40 updated_by: Option<String>,
41 closed_at: Option<String>,
42 /// The case's trail, oldest first: intake notes, reviews, refusal or completion.
43 #[serde(default)]
44 reviews: Vec<Review>,
45}
46#[derive(Deserialize, Serialize)]
47pub(crate) struct Review {
48 #[serde(default, skip_serializing)]
49 request_id: String,
50 at: String,
51 staff: String,
52 status: String,
53 evidence: String,
54}
55
56/// Same day next month, clamped to its last day; never a fixed 30-day delay.
57fn deadline(now: &str) -> String {
58 let mut year: u32 = now[0..4].parse().unwrap();
59 let month: u32 = now[5..7].parse().unwrap();
60 let day: u32 = now[8..10].parse().unwrap();
61 let month = if month == 12 {
62 year += 1;
63 1
64 } else {
65 month + 1
66 };
67 let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0);
68 let days = match month {
69 2 => {
70 if leap {
71 29
72 } else {
73 28
74 }
75 }
76 4 | 6 | 9 | 11 => 30,
77 _ => 31,
78 };
79 format!("{year:04}-{month:02}-{:02}{}", day.min(days), &now[10..])
80}
81
82impl Identity {
83 /// Requests received by email or verbally keep their original receipt date.
84 pub(crate) async fn intake_erasure(&self, a: IntakeArgs) -> Result<Outcome<bool>> {
85 let now = rfc3339(now_ms());
86 let received = g1t_contracts::time::parse_rfc3339(&a.received_at).map(rfc3339);
87 let Some(received) = received.filter(|at| at <= &now && at == &a.received_at) else {
88 return Ok(Outcome::fail(
89 FailureCode::Invalid,
90 "Use the original receipt time in UTC (YYYY-MM-DDTHH:MM:SS.sssZ), no later than now.",
91 ));
92 };
93 if a.staff.trim().is_empty() || a.evidence.trim().is_empty() || a.evidence.len() > 16000 {
94 return Ok(Outcome::fail(
95 FailureCode::Invalid,
96 "Record the source of the request and the identity verification needed or performed.",
97 ));
98 }
99 #[derive(Deserialize)]
100 struct Person {
101 id: String,
102 username: String,
103 }
104 let Some(person) = self.db.prepare("SELECT id, username FROM users WHERE username = ?1 UNION ALL SELECT user_id AS id, username FROM deleted_users WHERE username = ?1 LIMIT 1")
105 .bind(&[a.username.trim().to_lowercase().into()])?.first::<Person>(None).await? else {
106 return Ok(Outcome::fail(FailureCode::NotFound, "Account not found. Track requests about non-account data in the restricted support case system."));
107 };
108 // Earlier evidence of receipt moves the deadline earlier, never later.
109 // The intake notes join the open case's review trail either way, so a
110 // second channel's evidence is never dropped by the upsert.
111 let id = new_id("era", now_ms());
112 self.db.batch(vec![
113 self.db.prepare("INSERT INTO erasure_requests (id, user_id, username, requested_at, due_at, scope, evidence, updated_at, updated_by) VALUES (?, ?, ?, ?, ?, 'Personal data and account; assess shared content and lawful retention separately', ?, ?, ?) ON CONFLICT(user_id) WHERE closed_at IS NULL DO UPDATE SET requested_at = min(requested_at, excluded.requested_at), due_at = min(due_at, excluded.due_at)")
114 .bind(&[id.as_str().into(), person.id.as_str().into(), person.username.into(), received.as_str().into(), deadline(&received).into(), a.evidence.as_str().into(), now.as_str().into(), a.staff.as_str().into()])?,
115 self.db.prepare("INSERT INTO erasure_reviews (request_id, at, staff, status, evidence) SELECT id, ?2, ?3, 'intake', ?4 FROM erasure_requests WHERE user_id = ?1 AND closed_at IS NULL")
116 .bind(&[person.id.into(), now.into(), a.staff.into(), format!("Received {received}. {}", a.evidence).into()])?,
117 ]).await?;
118 Ok(Outcome::Ok(true))
119 }
120
121 pub(crate) async fn request_erasure(
122 &self,
123 a: RequestArgs,
124 ) -> Result<Outcome<serde_json::Value>> {
125 if !is_person(&a.user) {
126 return Ok(Outcome::fail(
127 FailureCode::Forbidden,
128 "Sign in as yourself to request erasure.",
129 ));
130 }
131 #[derive(Deserialize)]
132 struct Person {
133 username: String,
134 }
135 let Some(person) = self
136 .db
137 .prepare("SELECT username FROM users WHERE id = ? AND deleted_at IS NULL")
138 .bind(&[a.user.id.as_str().into()])?
139 .first::<Person>(None)
140 .await?
141 else {
142 return Ok(Outcome::fail(FailureCode::NotFound, "Account not found."));
143 };
144 let now = rfc3339(now_ms());
145 let due = deadline(&now);
146 self.db.prepare("INSERT OR IGNORE INTO erasure_requests (id, user_id, username, requested_at, due_at, scope, updated_at) VALUES (?, ?, ?, ?, ?, 'Personal data and account; assess shared content and lawful retention separately', ?)")
147 .bind(&[new_id("era", now_ms()).into(), a.user.id.as_str().into(), person.username.into(), now.as_str().into(), due.into(), now.into()])?.run().await?;
148 let row = self
149 .db
150 .prepare("SELECT * FROM erasure_requests WHERE user_id = ? AND closed_at IS NULL")
151 .bind(&[a.user.id.into()])?
152 .first::<ErasureRequest>(None)
153 .await?
154 .ok_or_else(|| worker::Error::RustError("Request not recorded".into()))?;
155 Ok(Outcome::Ok(
156 serde_json::json!({"id": row.id, "requested_at": row.requested_at, "due_at": row.due_at, "status": row.status}),
157 ))
158 }
159
160 pub(crate) async fn my_erasure(&self, a: UserArgs) -> Result<Option<serde_json::Value>> {
161 if !is_person(&a.user) {
162 return Ok(None);
163 }
164 // Internal notes and staff details are not part of the self-service status.
165 self.db.prepare("SELECT id, requested_at, due_at, status FROM erasure_requests WHERE user_id = ? ORDER BY requested_at DESC LIMIT 1")
166 .bind(&[a.user.id.into()])?.first(None).await
167 }
168
169 pub(crate) async fn erasure_requests(&self) -> Result<Vec<ErasureRequest>> {
170 const LISTED: &str = "SELECT id FROM erasure_requests ORDER BY closed_at IS NOT NULL, due_at LIMIT 500";
171 let mut requests = self.db.prepare(format!("SELECT * FROM erasure_requests WHERE id IN ({LISTED}) ORDER BY closed_at IS NOT NULL, due_at")).all().await?.results::<ErasureRequest>()?;
172 let reviews = self.db.prepare(format!("SELECT request_id, at, staff, status, evidence FROM erasure_reviews WHERE request_id IN ({LISTED}) ORDER BY id")).all().await?.results::<Review>()?;
173 for review in reviews {
174 if let Some(request) = requests.iter_mut().find(|r| r.id == review.request_id) {
175 request.reviews.push(review);
176 }
177 }
178 Ok(requests)
179 }
180
181 pub(crate) async fn review_erasure(&self, a: ReviewArgs) -> Result<Outcome<bool>> {
182 if a.staff.trim().is_empty()
183 || !["reviewing", "completed", "refused"].contains(&a.status.as_str())
184 || a.evidence.trim().is_empty()
185 || a.evidence.len() > 16000
186 {
187 return Ok(Outcome::fail(
188 FailureCode::Invalid,
189 "Choose a status and record the review evidence (up to 16,000 bytes).",
190 ));
191 }
192 let Some(case) = self
193 .db
194 .prepare("SELECT * FROM erasure_requests WHERE id = ? AND closed_at IS NULL")
195 .bind(&[a.id.as_str().into()])?
196 .first::<ErasureRequest>(None)
197 .await?
198 else {
199 return Ok(Outcome::fail(
200 FailureCode::NotFound,
201 "Open request not found.",
202 ));
203 };
204 if a.status == "completed" {
205 let pending = self.db.prepare("SELECT 1 FROM users WHERE id = ?1 UNION ALL SELECT 1 FROM purge_jobs WHERE subject_id = ?1 AND completed_at IS NULL LIMIT 1")
206 .bind(&[case.user_id.as_str().into()])?.first::<serde_json::Value>(None).await?.is_some();
207 if pending {
208 return Ok(Outcome::fail(
209 FailureCode::Conflict,
210 "Account purge and its cleanup jobs must finish before completion.",
211 ));
212 }
213 // Each part needs an actual outcome, including a reason for anything retained.
214 let evidence: serde_json::Value = match serde_json::from_str(&a.evidence) {
215 Ok(value) => value,
216 Err(_) => {
217 return Ok(Outcome::fail(
218 FailureCode::Invalid,
219 "Completion evidence must be a JSON object.",
220 ));
221 }
222 };
223 for key in [
224 "identity",
225 "downstream",
226 "shared_content",
227 "processors",
228 "backups",
229 "response",
230 "retention",
231 ] {
232 if evidence
233 .get(key)
234 .and_then(serde_json::Value::as_str)
235 .is_none_or(|v| v.trim().is_empty())
236 {
237 return Ok(Outcome::fail(
238 FailureCode::Invalid,
239 format!("Record the {key} outcome before completing this request."),
240 ));
241 }
242 }
243 }
244 let now = rfc3339(now_ms());
245 self.db.batch(vec![
246 self.db.prepare("INSERT INTO erasure_reviews (request_id, at, staff, status, evidence) SELECT ?1, ?2, ?3, ?4, ?5 WHERE EXISTS (SELECT 1 FROM erasure_requests WHERE id = ?1 AND closed_at IS NULL)")
247 .bind(&[a.id.as_str().into(), now.as_str().into(), a.staff.as_str().into(), a.status.as_str().into(), a.evidence.as_str().into()])?,
248 self.db.prepare("UPDATE erasure_requests SET status = ?2, evidence = ?3, updated_at = ?4, updated_by = ?5, closed_at = CASE WHEN ?2 IN ('completed','refused') THEN ?4 ELSE NULL END WHERE id = ?1 AND closed_at IS NULL")
249 .bind(&[a.id.into(), a.status.into(), a.evidence.into(), now.into(), a.staff.into()])?,
250 ]).await?;
251 Ok(Outcome::Ok(true))
252 }
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258 #[test]
259 fn calendar_month_deadlines() {
260 for (from, to) in [
261 ("2026-01-31", "2026-02-28"),
262 ("2028-01-31", "2028-02-29"),
263 ("2026-12-14", "2027-01-14"),
264 ("2026-10-14", "2026-11-14"),
265 ] {
266 assert_eq!(
267 deadline(&format!("{from}T15:30:00.123Z")),
268 format!("{to}T15:30:00.123Z")
269 );
270 }
271 }
272}