Skip to main content

alibi_core/
endpoint.rs

1//! Trusted endpoint calls keep logical input separate from an optional HTTP request.
2
3use crate::AuthContext;
4use crate::session::SessionRequest;
5use crate::types::RequestExtensions;
6use crate::utils::LockUnpoisoned;
7use crate::utils::json::JsValue;
8use crate::wire::{SessionView, UserView};
9use crate::{AuthError, AuthRequest, AuthResult, AuthSchema, Headers, HttpMethod};
10use serde::{Serialize, de::DeserializeOwned};
11use std::collections::HashMap;
12use std::marker::PhantomData;
13use std::sync::{Arc, Mutex};
14
15/// An operation registered by an installed plugin, independently of its HTTP routes.
16#[derive(Clone, Debug)]
17pub struct EndpointDefinition {
18    pub name: &'static str,
19    pub operation_id: &'static str,
20    pub path: Option<String>,
21    pub method: HttpMethod,
22}
23
24/// Typed operation input. Dispatch decodes the final input after before-hook patches.
25#[derive(Clone, Debug)]
26pub struct ServerEndpoint<T> {
27    plugin: &'static str,
28    name: &'static str,
29    body: Option<JsValue>,
30    query: Option<JsValue>,
31    params: Option<JsValue>,
32    output: PhantomData<fn() -> T>,
33}
34
35impl<T> ServerEndpoint<T> {
36    #[must_use]
37    pub const fn new(plugin: &'static str, name: &'static str) -> Self {
38        Self {
39            plugin,
40            name,
41            body: None,
42            query: None,
43            params: None,
44            output: PhantomData,
45        }
46    }
47
48    /// # Errors
49    /// Returns an error if application input cannot be serialized.
50    pub fn with_body(mut self, body: &impl Serialize) -> AuthResult<Self> {
51        self.body = Some(crate::utils::json::parse_value(
52            &crate::utils::json::to_string(body)?,
53        )?);
54        Ok(self)
55    }
56
57    /// # Errors
58    /// Returns an error if application input cannot be serialized.
59    pub fn with_query(mut self, query: &impl Serialize) -> AuthResult<Self> {
60        self.query = Some(crate::utils::json::parse_value(
61            &crate::utils::json::to_string(query)?,
62        )?);
63        Ok(self)
64    }
65
66    #[must_use]
67    pub fn with_body_value(mut self, body: JsValue) -> Self {
68        self.body = Some(body);
69        self
70    }
71
72    #[must_use]
73    pub fn with_query_value(mut self, query: JsValue) -> Self {
74        self.query = Some(query);
75        self
76    }
77
78    /// Supply logical path parameters independently of an HTTP request.
79    ///
80    /// # Errors
81    /// Returns an error if application input cannot be serialized.
82    pub fn with_params(mut self, params: &impl Serialize) -> AuthResult<Self> {
83        self.params = Some(crate::utils::json::parse_value(
84            &crate::utils::json::to_string(params)?,
85        )?);
86        Ok(self)
87    }
88
89    #[must_use]
90    pub fn with_params_value(mut self, params: JsValue) -> Self {
91        self.params = Some(params);
92        self
93    }
94
95    #[must_use]
96    pub const fn params(&self) -> Option<&JsValue> {
97        self.params.as_ref()
98    }
99
100    #[must_use]
101    pub const fn plugin(&self) -> &'static str {
102        self.plugin
103    }
104
105    #[must_use]
106    pub const fn name(&self) -> &'static str {
107        self.name
108    }
109
110    #[must_use]
111    pub fn input(self) -> (Option<JsValue>, Option<JsValue>) {
112        (self.body, self.query)
113    }
114}
115
116/// Per-call options. Absent headers remain distinct from an explicitly empty collection.
117#[derive(Clone, Debug, Default)]
118pub struct EndpointOptions {
119    pub headers: Option<HashMap<String, String>>,
120    pub request: Option<AuthRequest>,
121    pub method: Option<HttpMethod>,
122}
123
124#[derive(Clone, Debug, Default)]
125struct EndpointState {
126    session: Option<SessionView>,
127    principal: Option<(UserView, SessionView)>,
128    response_headers: Headers,
129    session_observation: Option<(UserView, SessionView)>,
130}
131
132struct VerifiedEndpointUser<S: AuthSchema> {
133    user: S::User,
134    config: Arc<crate::AuthConfig>,
135    database: Arc<dyn crate::AuthStore<S>>,
136}
137
138/// Runtime normalization phase, retaining absence in original logical inputs.
139#[derive(Clone, Copy, Debug, PartialEq, Eq)]
140pub enum EndpointPhase {
141    Raw,
142    Middleware,
143    Handler,
144}
145
146/// Genuine logical endpoint data available to hooks and application callbacks.
147///
148/// Clones share trusted session state, observed snapshots and queued headers.
149/// Every public dispatch constructs fresh state; supplied HTTP metadata cannot
150/// carry a caller-populated virtual principal into this context.
151#[derive(Clone, Debug)]
152pub struct EndpointCall {
153    operation_id: String,
154    path: Option<String>,
155    method: Option<HttpMethod>,
156    body: Option<JsValue>,
157    query: Option<JsValue>,
158    params: Option<JsValue>,
159    headers: Option<HashMap<String, String>>,
160    request: Option<AuthRequest>,
161    default_method: HttpMethod,
162    phase: EndpointPhase,
163    extensions: RequestExtensions,
164    state: Arc<Mutex<EndpointState>>,
165}
166
167impl EndpointCall {
168    #[must_use]
169    pub fn new(
170        definition: &EndpointDefinition,
171        body: Option<JsValue>,
172        query: Option<JsValue>,
173        options: EndpointOptions,
174    ) -> Self {
175        Self {
176            operation_id: definition.operation_id.into(),
177            path: definition.path.clone(),
178            method: options.method,
179            body,
180            query,
181            params: None,
182            headers: options.headers.map(|headers| {
183                headers
184                    .into_iter()
185                    .map(|(name, value)| (name.to_ascii_lowercase(), value))
186                    .collect()
187            }),
188            request: options.request.map(fresh_request),
189            default_method: definition.method.clone(),
190            phase: EndpointPhase::Raw,
191            extensions: RequestExtensions::default(),
192            state: Arc::new(Mutex::new(EndpointState::default())),
193        }
194    }
195
196    /// Logical HTTP endpoint input after physical routing and body parsing.
197    /// The original physical request remains separate from normalized hook input.
198    #[must_use]
199    pub fn from_http_request(request: &AuthRequest, route: &crate::AuthRoute) -> Self {
200        let body = request
201            .extensions()
202            .get::<crate::types::ParsedRequestBody>()
203            .and_then(|body| match body.as_ref() {
204                crate::types::ParsedRequestBody::Value(value) => Some(value.clone()),
205                crate::types::ParsedRequestBody::Opaque(_) => None,
206            });
207        let query = JsValue::Object(
208            request
209                .query
210                .keys()
211                .map(|name| {
212                    let values = request.query_values(name).unwrap_or_default();
213                    let value = if values.len() > 1 {
214                        JsValue::Array(values.iter().cloned().map(JsValue::String).collect())
215                    } else {
216                        JsValue::String(request.query.get(name).cloned().unwrap_or_default())
217                    };
218                    (name.clone(), value)
219                })
220                .collect(),
221        );
222        let definition = EndpointDefinition {
223            name: "http",
224            operation_id: "http",
225            path: Some(route.context_path.as_ref().unwrap_or(&route.path).clone()),
226            method: route.method.clone(),
227        };
228        let mut call = Self::new(
229            &definition,
230            body,
231            Some(query),
232            EndpointOptions {
233                headers: Some(request.headers.clone()),
234                request: Some(
235                    crate::hooks::current_request_hook_context()
236                        .map_or_else(|| request.clone(), |context| context.request),
237                ),
238                method: Some(request.method.clone()),
239            },
240        );
241        call.operation_id.clone_from(&route.operation_id);
242        call
243    }
244
245    #[must_use]
246    pub fn operation_id(&self) -> &str {
247        &self.operation_id
248    }
249
250    #[must_use]
251    pub fn path(&self) -> Option<&str> {
252        self.path.as_deref()
253    }
254
255    #[must_use]
256    pub const fn method(&self) -> Option<&HttpMethod> {
257        self.method.as_ref()
258    }
259
260    #[must_use]
261    pub const fn body(&self) -> Option<&JsValue> {
262        self.body.as_ref()
263    }
264
265    #[must_use]
266    pub const fn query(&self) -> Option<&JsValue> {
267        self.query.as_ref()
268    }
269
270    /// Retain supplied logical parameters in every genuine callback frame.
271    #[must_use]
272    pub fn with_params_value(mut self, params: JsValue) -> Self {
273        self.params = Some(params);
274        self
275    }
276
277    #[must_use]
278    pub const fn params(&self) -> Option<&JsValue> {
279        self.params.as_ref()
280    }
281
282    #[must_use]
283    pub const fn headers(&self) -> Option<&HashMap<String, String>> {
284        self.headers.as_ref()
285    }
286
287    /// Replace logical headers after a physical/plugin header transformation.
288    /// Session state and the distinct physical Request are retained.
289    pub fn replace_headers(&mut self, headers: HashMap<String, String>) {
290        self.headers = Some(headers);
291    }
292
293    #[must_use]
294    pub const fn request(&self) -> Option<&AuthRequest> {
295        self.request.as_ref()
296    }
297
298    #[must_use]
299    pub const fn phase(&self) -> EndpointPhase {
300        self.phase
301    }
302
303    #[must_use]
304    pub fn has_body(&self) -> bool {
305        self.phase != EndpointPhase::Raw || self.body.is_some()
306    }
307
308    #[must_use]
309    pub fn has_query(&self) -> bool {
310        self.phase != EndpointPhase::Raw || self.query.is_some()
311    }
312
313    #[must_use]
314    pub fn has_method(&self) -> bool {
315        self.phase != EndpointPhase::Raw || self.method.is_some()
316    }
317
318    /// Middleware callbacks have their own path/query defaults; matchers retain raw input.
319    #[must_use]
320    pub fn middleware_context(&self) -> Self {
321        let mut context = self.clone();
322        context.phase = EndpointPhase::Middleware;
323        if context.path.as_ref().is_none_or(String::is_empty) {
324            context.path = Some("/".into());
325        }
326        context
327    }
328
329    /// A handler receives validated data and its registered method/path defaults.
330    #[must_use]
331    pub fn handler_context(&self, body: Option<JsValue>, query: Option<JsValue>) -> Self {
332        let mut context = self.clone();
333        context.phase = EndpointPhase::Handler;
334        if context.path.as_ref().is_none_or(String::is_empty) {
335            context.path = Some("virtual:".into());
336        }
337        if context.method.is_none() {
338            context.method = Some(context.default_method.clone());
339        }
340        context.body = body;
341        context.query = query;
342        context
343    }
344
345    /// The nested session getter preserves caller input and validates its own query.
346    pub(crate) fn session_read_context(&self) -> Self {
347        let mut context = self.clone();
348        context.phase = EndpointPhase::Handler;
349        if context.path.as_ref().is_none_or(String::is_empty) {
350            context.path = Some("virtual:".into());
351        }
352        context.method = Some(HttpMethod::Get);
353        let mut query = indexmap::IndexMap::new();
354        for name in ["disableCookieCache", "disableRefresh"] {
355            if self
356                .query
357                .as_ref()
358                .and_then(|value| value.get(name))
359                .is_some()
360            {
361                _ = query.insert(name.into(), JsValue::Bool(self.session_query_truthy(name)));
362            }
363        }
364        context.query = Some(JsValue::Object(query));
365        context
366    }
367
368    #[must_use]
369    pub const fn extensions(&self) -> &RequestExtensions {
370        &self.extensions
371    }
372
373    #[must_use]
374    pub const fn default_method(&self) -> &HttpMethod {
375        &self.default_method
376    }
377
378    #[must_use]
379    pub fn virtual_session(&self) -> Option<SessionView> {
380        self.state.lock_unpoisoned().session.clone()
381    }
382
383    /// Retain the actual model and snapshot after a trusted hook has authenticated them.
384    /// This is an application/plugin authority boundary, never client input.
385    pub fn establish_session<S: AuthSchema>(
386        &self,
387        user: S::User,
388        user_view: UserView,
389        session: SessionView,
390        context: &AuthContext<S>,
391    ) {
392        self.extensions.insert(VerifiedEndpointUser::<S> {
393            user,
394            config: context.config.clone(),
395            database: context.database.clone(),
396        });
397        let mut state = self.state.lock_unpoisoned();
398        state.principal = Some((user_view, session.clone()));
399        state.session = Some(session);
400    }
401
402    /// Callback-visible authenticated data. This observation does not confer authority.
403    #[must_use]
404    pub fn session(&self) -> Option<(UserView, SessionView)> {
405        self.state.lock_unpoisoned().principal.clone()
406    }
407
408    pub(crate) fn clear_authenticated_session(&self) {
409        let mut state = self.state.lock_unpoisoned();
410        state.session = None;
411        state.principal = None;
412    }
413
414    pub fn record_authenticated_session(&self, user: UserView, session: SessionView) {
415        self.state.lock_unpoisoned().principal = Some((user, session));
416    }
417
418    #[must_use]
419    pub fn authenticated_user<S: AuthSchema>(&self, context: &AuthContext<S>) -> Option<S::User> {
420        self.extensions
421            .get::<VerifiedEndpointUser<S>>()
422            .filter(|user| {
423                Arc::ptr_eq(&user.config, &context.config)
424                    && Arc::ptr_eq(&user.database, &context.database)
425            })
426            .map(|user| user.user.clone())
427    }
428
429    pub fn set_response_header(&self, name: impl Into<String>, value: impl Into<String>) {
430        _ = self
431            .state
432            .lock_unpoisoned()
433            .response_headers
434            .insert(name, value);
435    }
436
437    pub fn queue_response_header(&self, name: impl Into<String>, value: impl Into<String>) {
438        self.state
439            .lock_unpoisoned()
440            .response_headers
441            .append(name, value);
442    }
443
444    pub fn take_response_headers(&self) -> Headers {
445        std::mem::take(&mut self.state.lock_unpoisoned().response_headers)
446    }
447
448    pub fn set_session_hook_snapshot(&self, user: UserView, session: SessionView) {
449        self.state.lock_unpoisoned().session_observation = Some((user, session));
450    }
451
452    #[must_use]
453    pub fn session_hook_snapshot(&self) -> Option<(UserView, SessionView)> {
454        self.state.lock_unpoisoned().session_observation.clone()
455    }
456
457    /// # Errors
458    /// Returns a typed decoding error for the current, post-hook body.
459    pub fn body_as<T: DeserializeOwned + 'static>(&self) -> AuthResult<T> {
460        Ok(crate::utils::json::from_value(
461            self.body.clone().unwrap_or(JsValue::Null),
462        )?)
463    }
464
465    /// # Errors
466    /// Returns a typed decoding error for the current, post-hook query.
467    pub fn query_as<T: DeserializeOwned + 'static>(&self) -> AuthResult<T> {
468        Ok(crate::utils::json::from_value(
469            self.query.clone().unwrap_or(JsValue::Null),
470        )?)
471    }
472}
473
474/// Context patches accumulate without replacing the original input seen by later before hooks.
475#[derive(Clone, Debug, Default)]
476pub struct EndpointContextPatch {
477    pub method: Option<HttpMethod>,
478    pub request: Option<AuthRequest>,
479    pub path: Option<String>,
480    pub body: Option<JsValue>,
481    pub query: Option<JsValue>,
482    pub params: Option<JsValue>,
483    pub headers: Option<HashMap<String, String>>,
484}
485
486impl EndpointContextPatch {
487    pub fn merge(&mut self, patch: Self) {
488        if let Some(method) = patch.method {
489            self.method = Some(method);
490        }
491        if let Some(request) = patch.request {
492            self.request = Some(request);
493        }
494        if let Some(path) = patch.path {
495            self.path = Some(path);
496        }
497        merge_optional(&mut self.body, patch.body);
498        merge_optional(&mut self.query, patch.query);
499        merge_optional(&mut self.params, patch.params);
500        if let Some(headers) = patch.headers {
501            self.headers.get_or_insert_with(HashMap::new).extend(
502                headers
503                    .into_iter()
504                    .map(|(name, value)| (name.to_ascii_lowercase(), value)),
505            );
506        }
507    }
508
509    pub fn apply(self, call: &mut EndpointCall) {
510        if let Some(method) = self.method {
511            call.method = Some(method);
512        }
513        if let Some(request) = self.request {
514            call.request = Some(fresh_request(request));
515        }
516        if let Some(path) = self.path {
517            call.path = Some(path);
518        }
519        merge_optional(&mut call.body, self.body);
520        merge_optional(&mut call.query, self.query);
521        merge_optional(&mut call.params, self.params);
522        if let Some(headers) = self.headers {
523            call.headers
524                .get_or_insert_with(HashMap::new)
525                .extend(headers);
526        }
527    }
528}
529
530fn merge_optional(target: &mut Option<JsValue>, patch: Option<JsValue>) {
531    if let Some(patch) = patch.filter(|value| !value.is_null()) {
532        if let Some(target) = target {
533            merge_value(target, patch);
534        } else {
535            *target = Some(patch);
536        }
537    }
538}
539
540fn merge_value(target: &mut JsValue, patch: JsValue) {
541    if let (JsValue::Object(target), JsValue::Object(patch)) = (&mut *target, &patch) {
542        for (name, value) in patch {
543            if value.is_null() || matches!(name.as_str(), "__proto__" | "constructor") {
544                continue;
545            }
546            if let Some(target) = target.get_mut(name) {
547                merge_value(target, value.clone());
548            } else {
549                _ = target.insert(name.clone(), value.clone());
550            }
551        }
552    } else {
553        *target = patch;
554    }
555}
556
557/// A before hook can accumulate input changes or finish without reaching the handler/after hooks.
558#[derive(Debug)]
559pub enum BeforeEndpointAction {
560    Patch(Box<EndpointContextPatch>),
561    Respond(EndpointResponse),
562    /// Reject with an intentional API error and its complete public body.
563    Reject(EndpointResponse),
564}
565
566/// Validated handler input. The dispatcher retains raw patched data for after hooks.
567#[derive(Clone, Debug, Default)]
568pub struct EndpointInput {
569    pub body: Option<JsValue>,
570    pub query: Option<JsValue>,
571}
572
573/// A returned value or intentional API error, available to completed endpoint hooks.
574#[derive(Debug)]
575pub struct EndpointResponse {
576    result: Result<JsValue, AuthError>,
577    http_response: Option<crate::AuthResponse>,
578    error_body: Option<JsValue>,
579    error_body_absent: bool,
580    headers: Headers,
581    status: Option<u16>,
582}
583
584impl EndpointResponse {
585    #[must_use]
586    pub fn value(value: JsValue) -> Self {
587        Self {
588            result: Ok(value),
589            http_response: None,
590            error_body: None,
591            error_body_absent: false,
592            headers: Headers::new(),
593            status: None,
594        }
595    }
596
597    /// # Errors
598    /// Returns an error if the endpoint value cannot be serialized.
599    pub fn json(value: &impl Serialize) -> AuthResult<Self> {
600        Ok(Self::value(crate::utils::json::parse_value(
601            &crate::utils::json::to_string(value)?,
602        )?))
603    }
604
605    /// Return no logical value and an empty successful HTTP response.
606    #[must_use]
607    pub fn empty() -> Self {
608        let mut response = Self::value(JsValue::Null);
609        response.http_response = Some(crate::AuthResponse::new(200));
610        response
611    }
612
613    /// Return an actual HTTP response value, retaining its bytes, status and headers.
614    ///
615    /// Endpoint hooks still run. Accumulated hook headers and status remain
616    /// separate from this response until it is forwarded to HTTP.
617    #[must_use]
618    pub fn from_http_response(response: crate::AuthResponse) -> Self {
619        let mut result = Self::value(JsValue::Object(indexmap::IndexMap::new()));
620        result.http_response = Some(response);
621        result
622    }
623
624    #[must_use]
625    pub const fn http_response(&self) -> Option<&crate::AuthResponse> {
626        self.http_response.as_ref()
627    }
628
629    pub fn http_response_mut(&mut self) -> Option<&mut crate::AuthResponse> {
630        self.http_response.as_mut()
631    }
632
633    /// Forward the returned value as HTTP, preserving a response value's own status and body.
634    ///
635    /// # Errors
636    /// Returns a serialization error for a JSON value or an intentional API error.
637    pub fn to_response(&self) -> AuthResult<crate::AuthResponse> {
638        let response = match &self.result {
639            Ok(value) => match &self.http_response {
640                Some(response) => response.clone(),
641                None => crate::AuthResponse::json(self.status.unwrap_or(200), value)?,
642            },
643            Err(error) => {
644                let (status, code, message) = error.error_payload();
645                let mut response = crate::AuthResponse::json(
646                    self.status.unwrap_or(status),
647                    &crate::ErrorCodeMessageResponse { code, message },
648                )?;
649                if self.error_body_absent {
650                    response.body.clear();
651                } else if let Some(body) = &self.error_body {
652                    response.body = crate::utils::json::to_vec(body)?;
653                }
654                response
655            }
656        };
657        Ok(forward_response_headers(response, &self.headers))
658    }
659
660    #[must_use]
661    pub fn error(error: AuthError) -> Self {
662        let status = Some(error.status_code());
663        Self {
664            result: Err(error),
665            http_response: None,
666            error_body: None,
667            error_body_absent: false,
668            headers: Headers::new(),
669            status,
670        }
671    }
672
673    /// Preserve an intentional redirect error, including its absent body and Location.
674    #[must_use]
675    pub fn redirect(location: impl Into<String>) -> Self {
676        Self::error(AuthError::Api {
677            status: 302,
678            code: None,
679            message: String::new(),
680        })
681        .without_error_body()
682        .with_header("location", location)
683    }
684
685    /// Retain the distinction between an absent error body and an explicit JSON null.
686    #[must_use]
687    pub fn without_error_body(mut self) -> Self {
688        self.error_body = None;
689        self.error_body_absent = true;
690        self
691    }
692
693    #[must_use]
694    pub const fn error_body_is_absent(&self) -> bool {
695        self.error_body_absent
696    }
697
698    #[must_use]
699    pub fn with_error_body(mut self, body: JsValue) -> Self {
700        self.error_body_absent = false;
701        self.error_body = Some(body);
702        self
703    }
704
705    #[must_use]
706    pub const fn error_body(&self) -> Option<&JsValue> {
707        self.error_body.as_ref()
708    }
709
710    pub const fn result(&self) -> &Result<JsValue, AuthError> {
711        &self.result
712    }
713
714    #[must_use]
715    pub const fn headers(&self) -> &Headers {
716        &self.headers
717    }
718
719    #[must_use]
720    pub const fn status(&self) -> Option<u16> {
721        self.status
722    }
723
724    pub fn set_status(&mut self, status: Option<u16>) {
725        self.status = status;
726    }
727
728    #[must_use]
729    pub fn with_status(mut self, status: u16) -> Self {
730        self.status = Some(status);
731        self
732    }
733
734    #[must_use]
735    pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
736        merge_header(&mut self.headers, name.into(), value.into());
737        self
738    }
739
740    pub fn merge_headers(&mut self, headers: Headers) {
741        for (name, value) in headers {
742            merge_header(&mut self.headers, name, value);
743        }
744    }
745
746    /// Replace a returned value while retaining the actual handler status and headers.
747    pub fn replace(&mut self, value: JsValue) {
748        self.result = Ok(value);
749        self.http_response = None;
750        self.error_body = None;
751        self.error_body_absent = false;
752    }
753
754    pub fn replace_error(&mut self, error: AuthError) {
755        self.result = Err(error);
756        self.http_response = None;
757        self.error_body = None;
758        self.error_body_absent = false;
759    }
760
761    pub fn into_output<T>(self) -> Result<EndpointOutput<T>, EndpointError> {
762        match self.result {
763            Ok(value) => Ok(EndpointOutput {
764                value,
765                http_response: self.http_response,
766                headers: self.headers,
767                status: self.status,
768                output: PhantomData,
769            }),
770            Err(error) => Err(EndpointError {
771                error,
772                headers: Some(self.headers),
773                body: self.error_body.map(Box::new),
774                body_absent: self.error_body_absent,
775            }),
776        }
777    }
778}
779
780fn merge_header(headers: &mut Headers, name: String, value: String) {
781    if name.eq_ignore_ascii_case("set-cookie") {
782        headers.append(name, value);
783    } else {
784        _ = headers.insert(name, value);
785    }
786}
787
788/// Installed application middleware for logical endpoint calls.
789#[async_trait::async_trait]
790pub trait EndpointHook<S: AuthSchema>: Send + Sync {
791    /// Matchers observe raw logical context, before middleware normalization.
792    /// # Errors
793    /// Returns an application matcher error, which dispatch logs and masks.
794    fn matches_before(&self, _call: &EndpointCall, _ctx: &AuthContext<S>) -> AuthResult<bool> {
795        Ok(true)
796    }
797
798    /// # Errors
799    /// Propagates actual callback failures without reaching later hooks or the handler.
800    async fn before(
801        &self,
802        _call: &EndpointCall,
803        _ctx: &AuthContext<S>,
804    ) -> AuthResult<Option<BeforeEndpointAction>> {
805        Ok(None)
806    }
807
808    /// # Errors
809    /// Propagates actual after-matcher failures.
810    fn matches_after(
811        &self,
812        _call: &EndpointCall,
813        _ctx: &AuthContext<S>,
814        _response: &EndpointResponse,
815    ) -> AuthResult<bool> {
816        Ok(true)
817    }
818
819    /// # Errors
820    /// API errors replace the result and continue; ordinary failures abort remaining hooks.
821    async fn after(
822        &self,
823        _call: &EndpointCall,
824        _ctx: &AuthContext<S>,
825        response: EndpointResponse,
826    ) -> AuthResult<EndpointResponse> {
827        Ok(response)
828    }
829}
830
831/// A successful call retains the actual value, since after hooks can replace its shape.
832#[derive(Debug)]
833pub struct EndpointOutput<T> {
834    value: JsValue,
835    http_response: Option<crate::AuthResponse>,
836    headers: Headers,
837    status: Option<u16>,
838    output: PhantomData<fn() -> T>,
839}
840
841impl<T> EndpointOutput<T> {
842    /// The unforwarded response value, separate from accumulated endpoint headers.
843    #[must_use]
844    pub const fn http_response(&self) -> Option<&crate::AuthResponse> {
845        self.http_response.as_ref()
846    }
847
848    /// Forward the result to HTTP, merging safe hook headers and repeated cookies.
849    ///
850    /// A response value retains its own body and status. A JSON replacement
851    /// from an after hook is serialized using the endpoint status instead.
852    ///
853    /// # Errors
854    /// Returns a serialization error for a JSON result.
855    pub fn into_response(self) -> AuthResult<crate::AuthResponse> {
856        let response = match self.http_response {
857            Some(response) => response,
858            None => crate::AuthResponse::json(self.status.unwrap_or(200), &self.value)?,
859        };
860        Ok(forward_response_headers(response, &self.headers))
861    }
862    #[must_use]
863    pub const fn value(&self) -> &JsValue {
864        &self.value
865    }
866
867    #[must_use]
868    pub const fn headers(&self) -> &Headers {
869        &self.headers
870    }
871
872    #[must_use]
873    pub const fn status(&self) -> Option<u16> {
874        self.status
875    }
876}
877
878impl<T: DeserializeOwned + 'static> EndpointOutput<T> {
879    /// # Errors
880    /// Returns an error if an application hook replaced the expected output type.
881    pub fn decode(&self) -> AuthResult<T> {
882        if self
883            .http_response
884            .as_ref()
885            .is_some_and(|response| !(response.body.is_empty() && self.value == JsValue::Null))
886        {
887            return Err(AuthError::internal(
888                "Endpoint returned an HTTP response; use http_response or into_response",
889            ));
890        }
891        Ok(crate::utils::json::from_value(self.value.clone())?)
892    }
893}
894
895fn forward_response_headers(
896    mut response: crate::AuthResponse,
897    headers: &Headers,
898) -> crate::AuthResponse {
899    for (name, value) in headers {
900        // Match the pinned HTTP conversion boundary: these are request-only,
901        // transport-managed or hop-by-hop headers, even when emitted by a hook.
902        if matches!(
903            name.to_ascii_lowercase().as_str(),
904            "host"
905                | "user-agent"
906                | "referer"
907                | "from"
908                | "expect"
909                | "authorization"
910                | "proxy-authorization"
911                | "cookie"
912                | "origin"
913                | "accept-charset"
914                | "accept-encoding"
915                | "accept-language"
916                | "if-match"
917                | "if-none-match"
918                | "if-modified-since"
919                | "if-unmodified-since"
920                | "if-range"
921                | "range"
922                | "max-forwards"
923                | "connection"
924                | "keep-alive"
925                | "transfer-encoding"
926                | "te"
927                | "upgrade"
928                | "trailer"
929                | "proxy-connection"
930                | "content-length"
931        ) {
932            continue;
933        }
934        merge_header(&mut response.headers, name.clone(), value.clone());
935    }
936    response
937}
938
939/// Actual application/API failure, with headers accumulated before an API error.
940#[derive(Debug)]
941pub struct EndpointError {
942    pub error: AuthError,
943    pub headers: Option<Headers>,
944    pub body: Option<Box<JsValue>>,
945    /// True when the API intentionally supplied no body, including redirect errors.
946    pub body_absent: bool,
947}
948
949impl From<AuthError> for EndpointError {
950    fn from(error: AuthError) -> Self {
951        Self {
952            error,
953            headers: None,
954            body: None,
955            body_absent: false,
956        }
957    }
958}
959
960impl std::fmt::Display for EndpointError {
961    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
962        self.error.fmt(formatter)
963    }
964}
965
966impl std::error::Error for EndpointError {
967    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
968        Some(&self.error)
969    }
970}
971
972/// Intentional endpoint/API errors continue through after hooks; ordinary failures propagate.
973#[must_use]
974pub fn is_endpoint_api_error(error: &AuthError) -> bool {
975    matches!(error, AuthError::Api { .. } | AuthError::Upstream { .. }) || error.status_code() < 500
976}
977
978fn fresh_request(request: AuthRequest) -> AuthRequest {
979    let pairs = request
980        .query
981        .keys()
982        .flat_map(|name| {
983            request
984                .query_values(name)
985                .into_iter()
986                .flatten()
987                .map(|value| (name.clone(), value.clone()))
988        })
989        .collect::<Vec<_>>();
990    let url = request.url().cloned();
991    let mut fresh = AuthRequest::from_parts(
992        request.method,
993        request.path,
994        request.headers,
995        request.body,
996        request.query,
997    );
998    if let Some(url) = url {
999        fresh = fresh.with_url(url);
1000    }
1001    fresh.set_query_pairs(pairs);
1002    fresh
1003}
1004
1005tokio::task_local! { static ENDPOINT_CALL_CONTEXT: Option<EndpointCall>; }
1006
1007#[must_use]
1008pub fn current_endpoint_call_context() -> Option<EndpointCall> {
1009    ENDPOINT_CALL_CONTEXT.try_with(Clone::clone).ok().flatten()
1010}
1011
1012pub async fn with_endpoint_call_context<T>(
1013    call: EndpointCall,
1014    future: impl Future<Output = T>,
1015) -> T {
1016    let request = call
1017        .request()
1018        .map(crate::hooks::RequestHookContext::from_request);
1019    crate::hooks::with_optional_request_hook_context(
1020        request,
1021        ENDPOINT_CALL_CONTEXT.scope(Some(call), future),
1022    )
1023    .await
1024}
1025
1026/// Isolate an independent HTTP dispatch from an enclosing logical endpoint call.
1027pub async fn without_endpoint_call_context<T>(future: impl Future<Output = T>) -> T {
1028    ENDPOINT_CALL_CONTEXT.scope(None, future).await
1029}