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