yeti_types/resource/context.rs
1//! `Context` — the request pipeline object, restructured into 5 typed blocks
2//! per ADR-011 (Stage 2/3/6).
3//!
4//! The five blocks:
5//!
6//! - **Required:** `request`, `lineage`
7//! - **Optional middleware-derived:** `routing`, `identity`
8//! - **Host-only** (not crossing the WIT ABI): `backend_manager`, `pubsub_lookup`,
9//! `requested_format`, `json_body`, `root_directory`
10//!
11//! `Response` is **not** a field of Context — it's instantiated alongside Context
12//! by the request pipeline and threaded through dispatch separately. In WIT it's
13//! an owned resource handle; here it's a value-typed [`Response`].
14
15use ecow::EcoString;
16use std::collections::HashMap;
17use std::sync::Arc;
18// `OnceLock` (not `OnceCell`) so `Context: Sync` holds — customer
19// resource bodies routinely pass `&Context` across `.await` points,
20// which requires `Sync`. wasm32-wasip2 is single-threaded so the
21// internal lock is uncontended; cost is negligible.
22#[cfg(target_arch = "wasm32")]
23use std::sync::OnceLock;
24
25use bytes::Bytes;
26
27use super::permission::TablePermission;
28use crate::auth::{Access, AuthIdentity};
29use crate::error::{Result, YetiError};
30
31// ============================================================================
32// Lazy WIT backing (wasm32 only)
33//
34// On the wasm guest side, the host hands the customer's dispatch
35// function an owned `Context` handle (mirrors wasi:http). The
36// guest-side bindgen-generated handle type doesn't live in
37// yeti-types — but yeti-types' `Context` needs to defer field
38// materialization until the customer reads a field. To square that,
39// we define an abstract `WitContextHandle` trait here and let the
40// `lib_gen_wasm`-emitted glue impl it on the bindgen-generated type.
41// Customer-facing `Context` accessors then call through `Box<dyn
42// WitContextHandle>` with one-time caching via `OnceCell`.
43// ============================================================================
44
45/// Wasm-guest-only: abstracts the WIT `borrow<context>` handle.
46/// `lib_gen_wasm`-generated guest code implements this on the
47/// bindgen-generated `Context` resource type so `yeti_types::resource::
48/// Context` can hold a trait-object handle and lazy-fetch fields via
49/// canonical-ABI accessor crossings.
50///
51/// Each `fetch_*` method is called at most once per Context (cached
52/// in [`LazyBacking`] `OnceCell`s).
53#[cfg(target_arch = "wasm32")]
54pub trait WitContextHandle: Send + Sync {
55 fn fetch_request(&self) -> Request;
56 fn fetch_routing(&self) -> Option<Routing>;
57 fn fetch_lineage(&self) -> Lineage;
58 fn fetch_identity(&self) -> Option<Identity>;
59}
60
61/// Wasm-guest-only: the per-Context lazy backing. Holds the WIT
62/// handle behind `Box<dyn>` and per-field `OnceCell`s that cache the
63/// first fetch. Only `request` and `routing` are lazy — `lineage`
64/// and `identity` are eager-fetched at construction because they're
65/// tiny and almost universally read (logging/authz).
66///
67/// Construction is via [`Context::from_wit_handle`] (called by
68/// `lib_gen_wasm`-generated `invoke_service`). Accessor methods on
69/// `Context` check `lazy` first; if present and the relevant
70/// `OnceCell` is uninitialized, they call through `handle` and cache.
71/// If `lazy` is `None`, the eager fields on `Context` are used
72/// (e.g., for `placeholder()` contexts used in queue-fn invocations).
73#[cfg(target_arch = "wasm32")]
74pub(crate) struct LazyBacking {
75 pub(crate) handle: Box<dyn WitContextHandle>,
76 pub(crate) request: OnceLock<Request>,
77 pub(crate) routing: OnceLock<Option<Routing>>,
78}
79
80#[cfg(target_arch = "wasm32")]
81impl std::fmt::Debug for LazyBacking {
82 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83 f.debug_struct("LazyBacking")
84 .field("handle", &"<dyn WitContextHandle>")
85 .finish_non_exhaustive()
86 }
87}
88
89// ============================================================================
90// Sub-records — the five typed blocks
91// ============================================================================
92
93/// Inbound request block: verb, protocol, path, body, headers, and parsed query.
94///
95/// `path` and `headers` are stored as `Arc` so that `Context::clone()` —
96/// which fires in WebSocket dispatch — is a refcount bump rather than a deep
97/// copy. The initial allocation per request is identical; the gain is on the
98/// clone path (WS upgrade, auth middleware that clones context, etc.).
99#[derive(Clone, Debug)]
100pub struct Request {
101 /// HTTP method (or the protocol-equivalent verb for non-HTTP transports).
102 pub verb: http::Method,
103 /// Wire protocol the request arrived on (REST / `WebSocket` / MQTT / …).
104 pub protocol: crate::schema::Protocol,
105 /// Request path, app-prefix included (e.g. `/yeti-auth/Users/alice`).
106 /// `EcoString`: inline (zero heap) for paths ≤ 15 bytes, Arc-backed clone
107 /// for longer paths. Either way `Context::clone()` is O(1).
108 pub path: EcoString,
109 /// Raw request body bytes (refcounted; cheap to clone).
110 pub body: Bytes,
111 /// Inbound request headers.
112 /// `Arc` so `Context::clone()` is a refcount bump, not a full `HeaderMap` copy.
113 pub headers: Arc<http::HeaderMap>,
114 /// Parsed query string, plus synthetic keys routing injects downstream.
115 pub query: HashMap<String, String>,
116}
117
118impl Default for Request {
119 fn default() -> Self {
120 Self {
121 verb: http::Method::GET,
122 protocol: crate::schema::Protocol::default(),
123 path: EcoString::new(),
124 body: Bytes::new(),
125 headers: Arc::new(http::HeaderMap::new()),
126 query: HashMap::new(),
127 }
128 }
129}
130
131/// Outbound response block: status, headers, and body bytes.
132#[derive(Clone, Debug, Default)]
133pub struct Response {
134 /// HTTP status code.
135 pub status: u16,
136 /// Response headers.
137 pub headers: http::HeaderMap,
138 /// Response body bytes (refcounted; cheap to clone).
139 pub body: Bytes,
140}
141
142/// Request-lineage block: identifiers and clock stamp for tracing/correlation.
143#[derive(Clone, Debug, Default)]
144pub struct Lineage {
145 /// Request id (`UUIDv7`), stamped by the inbound router.
146 pub request_id: String,
147 /// Parent request id when triggered by another request; `None` at the root.
148 pub parent: Option<String>,
149 /// Cross-request correlation id (e.g. inbound `X-Correlation-Id`).
150 pub correlation: Option<String>,
151 /// Deployment hash of the producing yeti-fabric node (`"local"` in dev).
152 pub deployment: String,
153 /// Hybrid Logical Clock stamp at request receipt.
154 pub hlc: Hlc,
155}
156
157/// Hybrid Logical Clock stamp — physical time plus a logical tiebreaker and node id.
158#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
159pub struct Hlc {
160 /// Physical time component (milliseconds since the Unix epoch).
161 pub physical: u64,
162 /// Logical counter, incremented for events sharing a `physical` tick.
163 pub logical: u32,
164 /// Originating node id, breaking ties across nodes.
165 pub node_id: u32,
166}
167
168/// Routing block: app/resource/database identifiers settled at request-routing time.
169#[derive(Clone, Debug)]
170pub struct Routing {
171 /// Application id — first URL segment, e.g. `"yeti-auth"`.
172 pub app: Arc<str>,
173 /// Resource id — second URL segment, e.g. `"Users"`. For table-backed
174 /// resources this is the table name.
175 pub resource: Arc<str>,
176 /// Database name — settled at request-routing time from the
177 /// `TableDefinition.database` (or app default `"data"` when the
178 /// table doesn't specify). **Distinct from `app`**: the
179 /// `@database` schema directive can point a table at a database
180 /// shared across apps. Permission checks read this field.
181 pub database: Arc<str>,
182 /// `None` = collection-level operation; `Some(k)` = single-record operation.
183 pub key: Option<String>,
184}
185
186/// Resolved identity block — the post-auth view that crosses the WIT ABI.
187#[derive(Clone, Debug)]
188pub struct Identity {
189 /// Authenticated principal (username / email / service id); `None` if anonymous.
190 pub principal: Option<String>,
191 /// Resolved role names.
192 pub roles: Vec<String>,
193 /// Resolved permission bundle (`super_user` bit + per-resource op masks).
194 pub permissions: Permissions,
195 /// Authentication method that produced this identity.
196 pub auth_method: AuthMethod,
197}
198
199/// Resolved permission bundle: a `super_user` override plus per-resource op masks.
200#[derive(Clone, Debug, Default)]
201pub struct Permissions {
202 /// When set, grants all operations on all resources (bypasses `per_resource`).
203 pub super_user: bool,
204 /// Per-resource operation grants.
205 pub per_resource: Vec<ResourcePerm>,
206}
207
208/// One resource's operation grant within a [`Permissions`] bundle.
209#[derive(Clone, Debug)]
210pub struct ResourcePerm {
211 /// Resource (table) name the mask applies to.
212 pub resource: String,
213 /// Allowed operations on `resource`.
214 pub ops: OpsMask,
215}
216
217/// Per-resource operation permission bitfield. Plain u8 wrapper to avoid
218/// a workspace-level bitflags dep at the foundation layer.
219#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
220pub struct OpsMask(pub u8);
221
222impl OpsMask {
223 /// Create / insert a record.
224 pub const CREATE: Self = Self(1 << 0);
225 /// Read / query a record.
226 pub const READ: Self = Self(1 << 1);
227 /// Update an existing record.
228 pub const UPDATE: Self = Self(1 << 2);
229 /// Delete a record.
230 pub const DELETE: Self = Self(1 << 3);
231 /// Subscribe to a record/collection change stream.
232 pub const SUBSCRIBE: Self = Self(1 << 4);
233 /// Publish to a topic/collection.
234 pub const PUBLISH: Self = Self(1 << 5);
235 /// Open a connection (`WebSocket` / MQTT).
236 pub const CONNECT: Self = Self(1 << 6);
237
238 /// An empty mask (no operations granted).
239 #[must_use]
240 pub const fn empty() -> Self {
241 Self(0)
242 }
243 /// `true` if `self` grants every bit set in `other`.
244 #[must_use]
245 pub const fn contains(self, other: Self) -> bool {
246 self.0 & other.0 == other.0
247 }
248 /// Set the bits in `other`.
249 pub const fn insert(&mut self, other: Self) {
250 self.0 |= other.0;
251 }
252 /// Clear the bits in `other`.
253 pub const fn remove(&mut self, other: Self) {
254 self.0 &= !other.0;
255 }
256}
257
258impl std::ops::BitOr for OpsMask {
259 type Output = Self;
260 fn bitor(self, rhs: Self) -> Self {
261 Self(self.0 | rhs.0)
262 }
263}
264
265/// The authentication scheme that produced a request's identity.
266#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
267pub enum AuthMethod {
268 /// Unauthenticated (anonymous / public endpoint).
269 #[default]
270 None,
271 /// HTTP Basic credentials.
272 Basic,
273 /// JWT bearer token.
274 Jwt,
275 /// OAuth session.
276 OAuth,
277 /// API key.
278 ApiKey,
279 /// Internal service token.
280 ServiceToken,
281}
282
283// ============================================================================
284// AuthBlock — aggregates the three authentication/authorization fields.
285// ============================================================================
286
287/// Authentication and authorization state aggregated into one nested block.
288///
289/// Holds the three host-side auth-related fields that flow through the
290/// request pipeline. Each sub-field carries distinct semantic content:
291///
292/// - [`identity_input`](Self::identity_input) — INPUT from a credential
293/// provider (Basic/JWT/OAuth/mTLS). Set by the auth pipeline's
294/// `AuthProvider::authenticate()`. Represents WHO the wire claims to
295/// be, before role resolution. Renamed from the flat `auth_identity`
296/// field to disambiguate from [`Context::identity`] (the WIT-mirrored
297/// *output* shape exposed to wasm apps).
298/// - [`access`](Self::access) — OUTPUT after the auth middleware
299/// resolves `identity_input` into a `User` + `Role`. Closed enum
300/// ([`Access`]). All RBAC helpers (`can_read_table`,
301/// `can_update_table`, attribute filtering) dispatch through this.
302/// - [`permission`](Self::permission) — Per-request cached
303/// [`TablePermission`] computed once by the auth layer for the
304/// currently-routed table and consumed on the dispatch hot path.
305/// `FullAccess` (super-user / dev mode / wildcard) or
306/// `AttributeRestricted` (narrow read/write lists). `Public`
307/// short-circuits all RBAC for `@export(public:[...])` tables.
308///
309/// **Relationship to `Context.identity`**: That top-level field is the
310/// pure-WIT *resolved* view (principal + roles + permissions bitmask
311/// shape) crossing the WIT boundary to wasm apps. `AuthBlock` is the
312/// host-side intermediate state used during dispatch. They overlap in
313/// content but differ in lifetime and WIT surface.
314#[derive(Clone, Debug)]
315pub struct AuthBlock {
316 /// Wire-level authentication claim, set by the auth pipeline before
317 /// role resolution. `None` for unauthenticated requests.
318 pub identity_input: Option<AuthIdentity>,
319 /// Resolved user + role after the auth middleware. `Access::None`
320 /// when unauthenticated.
321 pub access: Access,
322 /// Pre-computed table permission for the routed table, cached on
323 /// the request hot path.
324 pub permission: TablePermission,
325}
326
327impl Default for AuthBlock {
328 fn default() -> Self {
329 Self {
330 identity_input: None,
331 access: Access::None,
332 permission: TablePermission::FullAccess,
333 }
334 }
335}
336
337/// Result of dispatching a request: either a final response or a delegation.
338#[derive(Debug)]
339pub enum Outcome {
340 /// Terminal response — send it to the client.
341 Respond(Response),
342 /// Re-dispatch to another app/resource per the [`DelegateSpec`].
343 Delegate(DelegateSpec),
344}
345
346/// Target of an [`Outcome::Delegate`] — the app/resource/key to re-dispatch to.
347#[derive(Clone, Debug)]
348pub struct DelegateSpec {
349 /// Target application id.
350 pub app: String,
351 /// Target resource (table) name.
352 pub resource: String,
353 /// Target record key; `None` for a collection-level operation.
354 pub key: Option<String>,
355}
356
357// ============================================================================
358// Context — five-block, with host-only side-channel fields
359// ============================================================================
360
361/// Request context. Five typed blocks per ADR-011 plus host-only side fields.
362///
363/// The shape after Wave-3 collapse (2026-05-25):
364///
365/// - **WIT-mirrored blocks** crossing the ABI: `request`, `lineage`,
366/// `routing` (optional), `identity` (optional — the resolved view).
367/// - **Host-side auth state** consumed by the dispatch pipeline:
368/// [`auth: AuthBlock`](AuthBlock) holding `identity_input` (wire-level
369/// credential), `access` (resolved `User`+`Role`), and `permission`
370/// (per-request `TablePermission` cache).
371/// - **Host-only side-channel** for backend manager, pubsub lookup,
372/// parsed JSON body, requested content-type, and rootDirectory.
373///
374/// `auth.identity_input` is renamed from the old flat `auth_identity`
375/// field to avoid colliding with the WIT-side `identity` block above
376/// (which is the post-auth *resolved* shape that crosses the ABI).
377///
378/// `Clone` is implemented manually so the wasm-only `LazyBacking`
379/// field can be reset on clone (the underlying `Box<dyn
380/// WitContextHandle>` isn't itself `Clone`). Cloned wasm Contexts
381/// inherit eager fields only — host code paths use Clone freely.
382pub struct Context {
383 // ── 5-block shape (mirrors WIT yeti:types/core record context) ──
384 // ── WIT-backed fields ───────────────────────────────────────────────
385 //
386 // These four cross the WIT canonical-ABI boundary on wasm32
387 // (lazy-fetched via WitContextHandle for request/routing, eager
388 // for lineage/identity). Access through the accessor methods on
389 // this impl — direct field access is `pub(crate)` only so the
390 // host crate's bridge can construct/mutate them. Migration debt
391 // tracker: all external reads route through accessors per the
392 // wasi:http resource pattern.
393 pub(crate) request: Request,
394 pub(crate) lineage: Lineage,
395 pub(crate) routing: Option<Routing>,
396 pub(crate) identity: Option<Identity>,
397
398 /// Host-side authentication/authorization state consumed by the dispatch pipeline.
399 pub auth: AuthBlock,
400
401 // ── Host-only side-channel — not crossing the WIT ABI ──
402 /// Negotiated response content-type (from the `Accept` header).
403 pub requested_format: crate::content_type::ContentType,
404 /// Request body parsed as JSON — lazily on first access and cached.
405 /// Read via [`Context::json_body`] / `require_json_body`; requests that
406 /// never touch it (e.g. public/super-user writes) never pay the parse.
407 /// Boxed so the cell is one pointer wide: `OnceLock<Option<Box<_>>>` is
408 /// smaller than the bare `Option<Value>` it replaced, keeping the read
409 /// path (which never parses) from paying for a fatter `Context`.
410 json_body: std::sync::OnceLock<Option<Box<serde_json::Value>>>,
411 /// Backend manager for table access; `None` on contexts without storage.
412 pub backend_manager: Option<Arc<crate::backend::BackendManager>>,
413 /// Deployment root directory (rootDirectory), used to resolve on-disk paths.
414 pub root_directory: Arc<str>,
415 /// Lookup for a table's `PubSub` manager; `None` when pub/sub is unavailable.
416 pub pubsub_lookup: Option<crate::plugins::PubSubLookupFn>,
417
418 // ── Wasm-guest lazy backing — only present when constructed via
419 // [`Context::from_wit_handle`]. Accessor methods (`path`,
420 // `headers`, `routing`, ...) check this first and lazy-fetch
421 // if set. `None` means the Context uses the eager fields above
422 // (host build, or wasm-side placeholder).
423 #[cfg(target_arch = "wasm32")]
424 pub(crate) lazy: Option<LazyBacking>,
425}
426
427impl std::fmt::Debug for Context {
428 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
429 f.debug_struct("Context")
430 .field("request", &self.request)
431 .field("lineage", &self.lineage)
432 .field("routing", &self.routing)
433 .field("identity", &self.identity)
434 .field("auth", &self.auth)
435 .field("requested_format", &self.requested_format)
436 .field("json_body", &self.json_body.get())
437 .field("root_directory", &self.root_directory)
438 .finish_non_exhaustive()
439 }
440}
441
442impl Clone for Context {
443 fn clone(&self) -> Self {
444 Self {
445 request: self.request.clone(),
446 lineage: self.lineage.clone(),
447 routing: self.routing.clone(),
448 identity: self.identity.clone(),
449 auth: self.auth.clone(),
450 requested_format: self.requested_format,
451 json_body: self.json_body.clone(),
452 backend_manager: self.backend_manager.clone(),
453 root_directory: Arc::clone(&self.root_directory),
454 pubsub_lookup: self.pubsub_lookup.clone(),
455 // Wasm-only: lazy backing is NOT cloned — the
456 // `Box<dyn WitContextHandle>` has unique ownership of
457 // the WIT resource handle, and a clone would result in
458 // double-drop of the canonical-ABI resource. Cloned
459 // wasm Contexts behave as if constructed from the eager
460 // fields only.
461 #[cfg(target_arch = "wasm32")]
462 lazy: None,
463 }
464 }
465}
466
467impl Context {
468 /// Create a Context from a raw inbound request. Subsequent layers fill in
469 /// `routing` (router), `identity` (auth pipeline), and other side fields.
470 pub fn from_request(
471 method: http::Method,
472 path: impl Into<EcoString>,
473 body: Bytes,
474 headers: http::HeaderMap,
475 ) -> Self {
476 Self {
477 // Nested 5-block shape
478 request: Request {
479 verb: method,
480 protocol: crate::schema::Protocol::default(),
481 path: path.into(),
482 body,
483 headers: Arc::new(headers),
484 query: HashMap::new(),
485 },
486 lineage: Lineage::default(),
487 routing: None,
488 identity: None,
489 auth: AuthBlock::default(),
490 // Host-only side fields
491 requested_format: crate::content_type::ContentType::Json,
492 json_body: std::sync::OnceLock::new(),
493 backend_manager: None,
494 root_directory: Arc::from(""),
495 pubsub_lookup: None,
496 #[cfg(target_arch = "wasm32")]
497 lazy: None,
498 }
499 }
500
501 /// Wasm-guest constructor: wraps an owned WIT handle for lazy
502 /// field fetching. Eager fields default-empty; accessor methods
503 /// fetch + cache via the handle on first call.
504 ///
505 /// Emitted by `lib_gen_wasm`'s `invoke_service` so the customer's
506 /// resource handler receives a Context that only crosses the WIT
507 /// boundary for fields it actually reads.
508 #[cfg(target_arch = "wasm32")]
509 #[must_use]
510 pub fn from_wit_handle(handle: Box<dyn WitContextHandle>) -> Self {
511 // Lineage + identity are small and almost always touched
512 // (logging, authz) — eager-fetch them at construction.
513 // Request + routing carry headers/body/query and are the
514 // real lazy win, so they go behind `OnceCell` and only
515 // cross the WIT ABI when the customer actually reads them.
516 let lineage = handle.fetch_lineage();
517 let identity = handle.fetch_identity();
518 Self {
519 request: Request {
520 verb: http::Method::GET,
521 protocol: crate::schema::Protocol::default(),
522 path: EcoString::new(),
523 body: Bytes::new(),
524 headers: Arc::new(http::HeaderMap::new()),
525 query: HashMap::new(),
526 },
527 lineage,
528 routing: None,
529 identity,
530 auth: AuthBlock::default(),
531 requested_format: crate::content_type::ContentType::Json,
532 json_body: std::sync::OnceLock::new(),
533 backend_manager: None,
534 root_directory: Arc::from(""),
535 pubsub_lookup: None,
536 lazy: Some(LazyBacking {
537 handle,
538 request: OnceLock::new(),
539 routing: OnceLock::new(),
540 }),
541 }
542 }
543
544 /// Vacant Context used as a swap target for Tower `Service<Context>` chains.
545 #[must_use]
546 pub fn placeholder() -> Self {
547 Self::from_request(
548 http::Method::GET,
549 String::new(),
550 Bytes::new(),
551 http::HeaderMap::new(),
552 )
553 }
554
555 /// Context for `on_ready` / queue-worker / scheduled dispatch.
556 #[must_use]
557 pub fn for_service(
558 app_id: Arc<str>,
559 root_directory: Arc<str>,
560 backend_manager: Option<Arc<crate::backend::BackendManager>>,
561 pubsub_lookup: Option<crate::plugins::PubSubLookupFn>,
562 ) -> Self {
563 let mut ctx = Self::placeholder();
564 ctx.set_routing(Some(Routing {
565 app: app_id,
566 resource: Arc::from(""),
567 database: Arc::from(""),
568 key: None,
569 }));
570 ctx.root_directory = root_directory;
571 ctx.backend_manager = backend_manager;
572 ctx.pubsub_lookup = pubsub_lookup;
573 ctx
574 }
575
576 // ── Lazy field-fetch helpers (wasm guest only) ──
577 //
578 // On wasm32 with a `lazy` backing set (via [`Context::from_wit_handle`]),
579 // these resolve fields by calling the bindgen-generated WIT
580 // accessors through `Box<dyn WitContextHandle>` and caching in
581 // per-field `OnceCell`s. Without a backing (host build, or
582 // wasm-side `placeholder()`), they return the eager fields.
583
584 #[cfg(target_arch = "wasm32")]
585 #[inline]
586 fn request_ref(&self) -> &Request {
587 if let Some(lazy) = &self.lazy {
588 lazy.request.get_or_init(|| lazy.handle.fetch_request())
589 } else {
590 &self.request
591 }
592 }
593 #[cfg(not(target_arch = "wasm32"))]
594 #[inline]
595 const fn request_ref(&self) -> &Request {
596 &self.request
597 }
598
599 #[cfg(target_arch = "wasm32")]
600 #[inline]
601 fn routing_ref(&self) -> Option<&Routing> {
602 if let Some(lazy) = &self.lazy {
603 lazy.routing
604 .get_or_init(|| lazy.handle.fetch_routing())
605 .as_ref()
606 } else {
607 self.routing.as_ref()
608 }
609 }
610 #[cfg(not(target_arch = "wasm32"))]
611 #[inline]
612 const fn routing_ref(&self) -> Option<&Routing> {
613 self.routing.as_ref()
614 }
615
616 // ── Migration accessors — match old flat-field names as methods ──
617 //
618 // These read through `request_ref()`, which is `const` on the host
619 // but not on wasm32 (it lazy-fetches via `get_or_init`). They
620 // therefore can't be `const fn` across all targets; the host-build
621 // `missing_const_for_fn` is allowed per-accessor.
622
623 /// The request HTTP method.
624 #[inline]
625 #[allow(clippy::missing_const_for_fn)]
626 pub fn method(&self) -> &http::Method {
627 &self.request_ref().verb
628 }
629 /// The wire protocol the request arrived on.
630 #[inline]
631 #[allow(clippy::missing_const_for_fn)]
632 pub fn protocol(&self) -> crate::schema::Protocol {
633 self.request_ref().protocol
634 }
635 /// The request path (app-prefix included).
636 #[inline]
637 pub fn path(&self) -> &str {
638 &self.request_ref().path
639 }
640 /// The raw request body bytes.
641 #[inline]
642 #[allow(clippy::missing_const_for_fn)]
643 pub fn body(&self) -> &Bytes {
644 &self.request_ref().body
645 }
646 /// The inbound request headers.
647 #[inline]
648 #[allow(clippy::missing_const_for_fn)]
649 pub fn headers(&self) -> &http::HeaderMap {
650 &self.request_ref().headers
651 }
652 /// The routing block, when the router has populated it. `None`
653 /// only on raw-request contexts that bypassed the router (e.g.
654 /// test fixtures, queue-worker dispatch). Most call sites should
655 /// reach for the flat accessors (`app_id()`, `resource_id()`,
656 /// `database()`, etc.) instead.
657 #[inline]
658 pub const fn routing(&self) -> Option<&Routing> {
659 self.routing.as_ref()
660 }
661
662 /// Mutable access to the query-param map. Used by routing code
663 /// that augments the parsed query with synthetic keys (`_search`,
664 /// path-extracted values, etc.) before downstream handlers
665 /// observe it.
666 #[inline]
667 pub const fn query_params_mut(&mut self) -> &mut HashMap<String, String> {
668 &mut self.request.query
669 }
670
671 /// The parsed query-param map.
672 #[inline]
673 #[allow(clippy::missing_const_for_fn)]
674 pub fn query_params(&self) -> &HashMap<String, String> {
675 &self.request_ref().query
676 }
677
678 /// Application id from routing, or empty `Arc<str>` when unrouted.
679 #[inline]
680 pub fn app_id(&self) -> Arc<str> {
681 self.routing_ref()
682 .map_or_else(|| Arc::from(""), |r| Arc::clone(&r.app))
683 }
684 /// Resource (table) id from routing, or empty `Arc<str>` when unrouted.
685 #[inline]
686 pub fn resource_id(&self) -> Arc<str> {
687 self.routing_ref()
688 .map_or_else(|| Arc::from(""), |r| Arc::clone(&r.resource))
689 }
690 /// Record key from routing, or `""` for a collection-level operation.
691 #[inline]
692 pub fn path_id(&self) -> &str {
693 self.routing_ref()
694 .and_then(|r| r.key.as_deref())
695 .unwrap_or("")
696 }
697 /// `true` when this is a collection-level operation (no record key).
698 #[inline]
699 pub fn is_collection(&self) -> bool {
700 self.routing_ref().is_none_or(|r| r.key.is_none())
701 }
702 /// Database name. Settled at request-routing time from
703 /// `TableDefinition.database` (or the default `"data"` when the
704 /// table doesn't specify). **Distinct from `app_id`** — the
705 /// `@database` schema directive can point a table at a database
706 /// shared across apps. Returns empty string when no routing.
707 #[inline]
708 pub fn database(&self) -> Arc<str> {
709 self.routing_ref()
710 .map_or_else(|| Arc::from(""), |r| Arc::clone(&r.database))
711 }
712 /// Table name. For table-backed resources the resource name IS the
713 /// table name.
714 #[inline]
715 pub fn table_name(&self) -> Arc<str> {
716 self.routing_ref()
717 .map_or_else(|| Arc::from(""), |r| Arc::clone(&r.resource))
718 }
719
720 // ── Mutators (for setter call sites) ──
721
722 /// Set the request method.
723 pub fn set_method(&mut self, m: http::Method) {
724 self.request.verb = m;
725 }
726 /// Set the request path.
727 pub fn set_path(&mut self, p: impl Into<EcoString>) {
728 self.request.path = p.into();
729 }
730 /// Set the request body.
731 pub fn set_body(&mut self, b: Bytes) {
732 self.request.body = b;
733 }
734 /// Set the request headers.
735 pub fn set_headers(&mut self, h: http::HeaderMap) {
736 self.request.headers = Arc::new(h);
737 }
738 /// Set the wire protocol.
739 pub const fn set_protocol(&mut self, p: crate::schema::Protocol) {
740 self.request.protocol = p;
741 }
742 /// Replace the parsed query-param map.
743 pub fn set_query_params(&mut self, q: HashMap<String, String>) {
744 self.request.query = q;
745 }
746 /// Set the routing app id (creating a `Routing` block if absent).
747 pub fn set_app_id(&mut self, app_id: Arc<str>) {
748 match &mut self.routing {
749 Some(r) => r.app = app_id,
750 None => {
751 self.set_routing(Some(Routing {
752 app: app_id,
753 resource: Arc::from(""),
754 database: Arc::from(""),
755 key: None,
756 }));
757 },
758 }
759 }
760 /// Set the routing resource id (creating a `Routing` block if absent).
761 pub fn set_resource_id(&mut self, resource_id: Arc<str>) {
762 match &mut self.routing {
763 Some(r) => r.resource = resource_id,
764 None => {
765 self.set_routing(Some(Routing {
766 app: Arc::from(""),
767 resource: resource_id,
768 database: Arc::from(""),
769 key: None,
770 }));
771 },
772 }
773 }
774 /// Set the routing record key (empty string clears it to collection-level).
775 pub fn set_path_id(&mut self, key: impl Into<String>) {
776 let key_s = key.into();
777 let key_opt = if key_s.is_empty() { None } else { Some(key_s) };
778 match &mut self.routing {
779 Some(r) => r.key = key_opt,
780 None => {
781 self.set_routing(Some(Routing {
782 app: Arc::from(""),
783 resource: Arc::from(""),
784 database: Arc::from(""),
785 key: key_opt,
786 }));
787 },
788 }
789 }
790 /// Clear the record key when `is_coll` is `true` (makes the op collection-level).
791 pub fn set_is_collection(&mut self, is_coll: bool) {
792 if is_coll && let Some(r) = &mut self.routing {
793 r.key = None;
794 }
795 // Setting is_collection=false without a key is meaningless; ignore.
796 }
797 /// Set the `database` field on the current `Routing` (creating a
798 /// `Routing` block if none exists). Called by the router after it
799 /// resolves the target table's `@database` directive.
800 pub fn set_database(&mut self, db: Arc<str>) {
801 match &mut self.routing {
802 Some(r) => r.database = db,
803 None => {
804 self.set_routing(Some(Routing {
805 app: Arc::from(""),
806 resource: Arc::from(""),
807 database: db,
808 key: None,
809 }));
810 },
811 }
812 }
813 /// Set the routing table name (aliases the resource id for table-backed resources).
814 pub fn set_table_name(&mut self, name: Arc<str>) {
815 // Table name aliases resource for table-backed resources.
816 match &mut self.routing {
817 Some(r) => r.resource = name,
818 None => {
819 self.set_routing(Some(Routing {
820 app: Arc::from(""),
821 resource: name,
822 database: Arc::from(""),
823 key: None,
824 }));
825 },
826 }
827 }
828
829 // ── Convenience accessors carried over from the old shape ──
830
831 /// Root directory accessor.
832 #[inline]
833 pub fn root_dir(&self) -> &str {
834 &self.root_directory
835 }
836
837 /// Application ID as `&str`.
838 #[inline]
839 pub fn app_id_str(&self) -> &str {
840 self.routing.as_ref().map_or("", |r| &r.app)
841 }
842
843 /// Resource ID as `&str` — borrow-returning counterpart to
844 /// `resource_id()` (which returns an owned `Arc<str>`).
845 #[inline]
846 pub fn resource_id_str(&self) -> &str {
847 self.routing.as_ref().map_or("", |r| &r.resource)
848 }
849
850 /// `PubSub` manager for a named table.
851 pub fn pubsub(&self, table_name: &str) -> Option<Arc<crate::pubsub::PubSubManager>> {
852 self.pubsub_lookup.as_ref().and_then(|f| f(table_name))
853 }
854
855 /// Raw table backend accessor.
856 pub fn backend(&self, name: &str) -> Option<Arc<dyn crate::backend::KvBackend>> {
857 self.backend_manager
858 .as_ref()
859 .and_then(|bm| bm.get_backend_for_table(name).ok())
860 }
861
862 /// Raw table backend accessor that errors when missing.
863 ///
864 /// # Errors
865 /// `YetiError::Internal` when no backend is attached or registered for `name`.
866 pub fn require_backend(&self, name: &str) -> Result<Arc<dyn crate::backend::KvBackend>> {
867 self.backend_manager
868 .as_ref()
869 .ok_or_else(|| YetiError::Internal("backend_manager not available".to_owned()))?
870 .get_backend_for_table(name)
871 }
872
873 /// A named query parameter, or `None` when absent.
874 #[inline]
875 pub fn query(&self, name: &str) -> Option<&str> {
876 self.request.query.get(name).map(String::as_str)
877 }
878
879 /// A named query parameter parsed as `i64`, falling back to `default`.
880 #[inline]
881 pub fn query_int(&self, name: &str, default: i64) -> i64 {
882 self.query(name)
883 .and_then(|v| v.parse().ok())
884 .unwrap_or(default)
885 }
886
887 /// A named query parameter parsed as a bool (`true`/`1`/`yes`/`on`),
888 /// falling back to `default`.
889 #[inline]
890 pub fn query_bool(&self, name: &str, default: bool) -> bool {
891 self.query(name).map_or(default, |v| {
892 matches!(v.to_lowercase().as_str(), "true" | "1" | "yes" | "on")
893 })
894 }
895
896 /// Get the database and table name as a pair (for permission checks).
897 /// Database comes from `routing.database` (settled at routing time
898 /// from the table's `@database` directive); table comes from
899 /// `routing.resource`.
900 #[inline]
901 pub fn table_context(&self) -> (&str, &str) {
902 self.routing
903 .as_ref()
904 .map_or(("", ""), |r| (&r.database, &r.resource))
905 }
906
907 /// Database name as `&str`. See [`Self::database`] for the full
908 /// distinction between `database` and `app_id`.
909 #[inline]
910 pub fn database_str(&self) -> &str {
911 self.routing.as_ref().map_or("", |r| &r.database)
912 }
913
914 /// Table name as `&str`. For table-backed resources, the resource name
915 /// IS the table name.
916 #[inline]
917 pub fn table_name_str(&self) -> &str {
918 self.routing.as_ref().map_or("", |r| &r.resource)
919 }
920
921 /// The request body parsed as JSON — lazily on first call, cached
922 /// thereafter. `None` for an empty or non-JSON body. Requests that
923 /// never read it (e.g. public/super-user writes that skip
924 /// attribute-level permission checks) never pay the parse. Goes
925 /// through `body()` so the wasm lazy backing materializes `request`
926 /// first.
927 #[must_use]
928 pub fn json_body(&self) -> Option<&serde_json::Value> {
929 self.json_body
930 .get_or_init(|| {
931 let body = self.body();
932 if body.is_empty() {
933 None
934 } else {
935 serde_json::from_slice::<serde_json::Value>(body.as_ref())
936 .ok()
937 .map(Box::new)
938 }
939 })
940 .as_deref()
941 }
942
943 /// Force the lazy [`Context::json_body`] parse now. Useful when a
944 /// later `&self` reader shouldn't pay the cost, or to pre-warm the
945 /// cache. No-op once cached or for an empty body.
946 pub fn parse_body(&mut self) {
947 let _ = self.json_body();
948 }
949
950 // ── AuthBlock accessors — borrow-returning helpers for the
951 // common read paths. Setters write directly into `ctx.auth.*`.
952 // Callers needing the owned `Access` clone reach via `ctx.auth.access`.
953
954 /// Resolved [`Access`] for this request. Defaults to [`Access::None`]
955 /// when unauthenticated.
956 #[inline]
957 pub const fn access(&self) -> &Access {
958 &self.auth.access
959 }
960
961 /// Cached per-request [`TablePermission`] decision.
962 #[inline]
963 pub const fn permission(&self) -> &TablePermission {
964 &self.auth.permission
965 }
966
967 /// Wire-level authentication claim produced by an
968 /// `AuthProvider::authenticate()` call. `None` for unauthenticated
969 /// requests.
970 #[inline]
971 pub const fn auth_identity(&self) -> Option<&AuthIdentity> {
972 self.auth.identity_input.as_ref()
973 }
974
975 // ── Lineage accessors ────────────────────────────────────────────────
976 //
977 // The `lineage` field is eager-materialized at construction (small,
978 // near-universal reads — logging, request_id). Accessors here are
979 // the canonical surface; direct `ctx.lineage.*` access is migration
980 // debt counting down to zero.
981
982 /// Request id (`UUIDv7`) — stamped by the inbound router, propagates
983 /// through HLC + logs.
984 #[inline]
985 pub fn request_id(&self) -> &str {
986 &self.lineage.request_id
987 }
988
989 /// Parent request id when this request was triggered by another
990 /// (e.g. queue worker dispatching a job's child call).
991 #[inline]
992 pub fn parent_id(&self) -> Option<&str> {
993 self.lineage.parent.as_deref()
994 }
995
996 /// Cross-request correlation id (e.g. customer's `X-Correlation-Id`
997 /// header). `None` when no correlation was passed.
998 #[inline]
999 pub fn correlation_id(&self) -> Option<&str> {
1000 self.lineage.correlation.as_deref()
1001 }
1002
1003 /// Deployment hash — identifies the yeti-fabric node that produced
1004 /// this request. Defaults to `"local"` for single-tenant dev.
1005 #[inline]
1006 pub fn deployment(&self) -> &str {
1007 &self.lineage.deployment
1008 }
1009
1010 /// Hybrid Logical Clock stamp at request receipt. `Copy`, so
1011 /// returned by value.
1012 #[inline]
1013 #[must_use]
1014 pub const fn hlc(&self) -> Hlc {
1015 self.lineage.hlc
1016 }
1017
1018 // ── Identity accessors ───────────────────────────────────────────────
1019 //
1020 // Eager-materialized at construction. `identity` is `Option<Identity>`
1021 // — the accessor exposes it as `Option<&Identity>` so call sites can
1022 // chain `.map(...)` without cloning, or pattern-match with
1023 // `if let Some(id) = ctx.identity() { ... }`.
1024
1025 /// Resolved request identity — `None` for unauthenticated requests
1026 /// or paths that bypass the auth pipeline (public endpoints).
1027 #[inline]
1028 pub const fn identity(&self) -> Option<&Identity> {
1029 self.identity.as_ref()
1030 }
1031
1032 /// Convenience: the authenticated principal (username / email /
1033 /// service id) when the request resolved an identity. Equivalent
1034 /// to `ctx.identity().and_then(|i| i.principal.as_deref())`.
1035 #[inline]
1036 pub fn principal(&self) -> Option<&str> {
1037 self.identity.as_ref().and_then(|i| i.principal.as_deref())
1038 }
1039
1040 /// Convenience: the authenticated role set, or empty slice when
1041 /// unauthenticated. Always-safe-to-iterate shape; for permission
1042 /// checks prefer [`Self::permissions`] which carries the resolved
1043 /// `super_user` bit + per-resource ops mask.
1044 #[inline]
1045 pub fn roles(&self) -> &[String] {
1046 self.identity
1047 .as_ref()
1048 .map_or(&[][..], |i| i.roles.as_slice())
1049 }
1050
1051 /// Convenience: the resolved permission bundle (`super_user` +
1052 /// per-resource op mask). Returns a default-empty `Permissions`
1053 /// when unauthenticated so call sites can chain `.super_user`
1054 /// without an outer `Option` dance.
1055 #[inline]
1056 pub fn permissions(&self) -> Option<&Permissions> {
1057 self.identity.as_ref().map(|i| &i.permissions)
1058 }
1059
1060 /// Convenience: the authentication method (basic / jwt / oauth /
1061 /// api-key / service-token / none).
1062 #[inline]
1063 #[must_use]
1064 pub fn auth_method(&self) -> AuthMethod {
1065 self.identity
1066 .as_ref()
1067 .map_or(AuthMethod::None, |i| i.auth_method)
1068 }
1069
1070 // ── Lineage setters ─────────────────────────────────────────────────
1071 //
1072 // Test fixtures + dispatch glue need to stamp lineage fields after
1073 // construction. The setters keep the field-write surface contained
1074 // so the underlying `lineage` field can move to `pub(crate)`.
1075
1076 /// Set the request id.
1077 pub fn set_request_id(&mut self, id: impl Into<String>) {
1078 self.lineage.request_id = id.into();
1079 }
1080
1081 /// Set the parent request id.
1082 pub fn set_parent_id(&mut self, parent: Option<String>) {
1083 self.lineage.parent = parent;
1084 }
1085
1086 /// Set the cross-request correlation id.
1087 pub fn set_correlation_id(&mut self, c: Option<String>) {
1088 self.lineage.correlation = c;
1089 }
1090
1091 /// Set the deployment hash.
1092 pub fn set_deployment(&mut self, d: impl Into<String>) {
1093 self.lineage.deployment = d.into();
1094 }
1095
1096 /// Set the HLC stamp.
1097 pub const fn set_hlc(&mut self, hlc: Hlc) {
1098 self.lineage.hlc = hlc;
1099 }
1100
1101 // ── Identity / auth setters ─────────────────────────────────────────
1102 //
1103 // Auth pipeline middleware writes through these after resolving the
1104 // inbound credential into a User + Role. Test fixtures also stamp
1105 // these to simulate authenticated requests.
1106
1107 /// Set the resolved identity block.
1108 pub fn set_identity(&mut self, identity: Option<Identity>) {
1109 self.identity = identity;
1110 }
1111
1112 /// Set the routing block.
1113 pub fn set_routing(&mut self, routing: Option<Routing>) {
1114 self.routing = routing;
1115 }
1116
1117 /// Set the resolved [`Access`].
1118 pub fn set_access(&mut self, access: Access) {
1119 self.auth.access = access;
1120 }
1121
1122 /// Set the cached [`TablePermission`] decision.
1123 pub fn set_permission(&mut self, p: TablePermission) {
1124 self.auth.permission = p;
1125 }
1126
1127 /// Set the wire-level authentication claim.
1128 pub fn set_auth_identity(&mut self, id: Option<AuthIdentity>) {
1129 self.auth.identity_input = id;
1130 }
1131
1132 /// Mutable [`Access`] for the auth pipeline's save/restore pattern:
1133 /// `std::mem::take(ctx.access_mut())` swaps in `Access::default()`
1134 /// so the resolver runs against a clean slate, then restoration
1135 /// writes back via [`Self::set_access`].
1136 #[inline]
1137 pub const fn access_mut(&mut self) -> &mut Access {
1138 &mut self.auth.access
1139 }
1140
1141 /// Mutable wire-level authentication claim (companion to
1142 /// [`Self::access_mut`] for the same save/restore pattern).
1143 #[inline]
1144 pub const fn auth_identity_mut(&mut self) -> &mut Option<AuthIdentity> {
1145 &mut self.auth.identity_input
1146 }
1147}