plexus_auth_core/credential.rs
1//! `Credential<T>`, `CredentialMinter`, and `CredentialMetadata` — sealed
2//! framework-level credential primitive.
3//!
4//! Per `AUTHZ-CRED-CORE-1` and AUTHZ-0 principle 6 ("the user's safety property
5//! is unforgeable"), a credential value is unforgeable from activation code.
6//! The compiler enforces this: there is no public constructor for
7//! [`Credential<T>`] reachable from outside `plexus-auth-core`, no public
8//! mutator, no `Default`, no `Deserialize`. The only path to producing a
9//! `Credential<T>` is via [`CredentialMinter::mint`], whose constructor is
10//! itself `pub(crate)` — the framework's dispatch layer (`plexus-core` /
11//! `plexus-transport`, in a follow-up ticket) is the only caller that can
12//! obtain a minter, and activation code receives an immutable reference.
13//!
14//! # Sealing summary (per AUTHZ-0 §"Crate-level isolation amplifies the seal")
15//!
16//! | Protection | Mechanism |
17//! |----------------------|------------------------------------------------|
18//! | No fabrication | `Credential::new_sealed` is `pub(crate)` |
19//! | No backdoor From/Into| Orphan rules forbid foreign-trait impls |
20//! | No accidental Default| Not derived |
21//! | No leaky Deserialize | Not derived; serialize-only round-trip |
22//! | No mutation | Fields private; no `&mut` accessors |
23//! | No raw secret on wire| Custom `Serialize` emits a sentinel by default |
24//!
25//! # Sentinel-emitting serialization (Tier B Q-WIRE-3)
26//!
27//! `Credential<T>`'s `Serialize` impl emits, by default, a sentinel reference
28//! of the shape `{"$credential": "<id>"}` rather than the inner value. The
29//! framework's dispatch layer (per `AUTHZ-CRED-CORE-2`) flips a thread-local
30//! toggle via an RAII guard for the duration of envelope-building; while the
31//! toggle is set, the same `Serialize` impl additionally captures the value
32//! into a dispatch-side sidecar (see `with_dispatch_capture` — crate-private,
33//! reachable only inside `plexus-auth-core`). When the
34//! toggle is unset (the default — any naive `serde_json::to_value(&payload)`
35//! from application code, audit-log writers, or trace formatters), only the
36//! sentinel is emitted; the inner value never appears in the produced JSON.
37//!
38//! The toggle's setter is `pub(crate)`. Activation code has no public path
39//! to it. The guard's `Drop` impl clears the toggle even on panic so a
40//! mid-serialization panic cannot leak the value.
41//!
42//! See `tests/compile_fail/credential_*.rs` for the structural enforcement
43//! asserts.
44
45use std::cell::RefCell;
46use std::collections::HashMap;
47use std::sync::atomic::{AtomicU64, Ordering};
48
49use chrono::{DateTime, Utc};
50use schemars::JsonSchema;
51use serde::ser::{SerializeMap, Serializer};
52use serde::{Deserialize, Serialize};
53
54// ---------------------------------------------------------------------------
55// Strong-typed newtypes (per the strong-typing skill).
56//
57// Every metadata field that would otherwise be a bare `String` is a newtype
58// so the compiler catches accidental misuse. These mirror the names pinned
59// by AUTHZ-S01-output §1 and CLIENTS-S01-output §1; when those upstream
60// types land in shared crates this module's `pub use` aliases get retargeted
61// (see ticket §"Risks" #3 — local-stub-now / refactor-on-fast-follow).
62// ---------------------------------------------------------------------------
63
64macro_rules! string_newtype {
65 (
66 $(#[$meta:meta])*
67 $name:ident
68 ) => {
69 $(#[$meta])*
70 #[derive(
71 Debug,
72 Clone,
73 PartialEq,
74 Eq,
75 Hash,
76 PartialOrd,
77 Ord,
78 Serialize,
79 Deserialize,
80 JsonSchema,
81 )]
82 #[serde(transparent)]
83 pub struct $name(String);
84
85 impl $name {
86 /// Wrap a string as a typed value.
87 pub fn new(s: impl Into<String>) -> Self {
88 Self(s.into())
89 }
90
91 /// Borrow the underlying string.
92 pub fn as_str(&self) -> &str {
93 &self.0
94 }
95
96 /// Consume into the underlying string.
97 pub fn into_string(self) -> String {
98 self.0
99 }
100 }
101
102 impl std::fmt::Display for $name {
103 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104 f.write_str(&self.0)
105 }
106 }
107
108 impl From<String> for $name {
109 fn from(s: String) -> Self {
110 Self(s)
111 }
112 }
113
114 impl From<&str> for $name {
115 fn from(s: &str) -> Self {
116 Self(s.to_owned())
117 }
118 }
119 };
120}
121
122// MethodPath, HeaderName, CookieName are re-exported from `crate::capabilities`
123// (AUTHZ-CORE-3, which landed first). The wire-validated `try_new` constructors
124// live there.
125pub use crate::capabilities::{CookieName, HeaderName, MethodPath};
126
127string_newtype! {
128 /// Atomic capability identifier, e.g. `cone.send_message`. Local stub
129 /// for the canonical `Scope` newtype pinned by AUTHZ-S01-output §1.
130 Scope
131}
132
133string_newtype! {
134 /// Backend Origin identifier (typically a URL like `ws://localhost:4444`).
135 /// Local stub for the canonical `Origin` newtype pinned by
136 /// CLIENTS-S01-output §1. NOTE: the workspace also has an unrelated
137 /// `plexus_core::types::Origin` (plugin-id + method, not URL-shaped); the
138 /// AUTHZ-CRED design uses the URL-shaped Origin from CLIENTS-S01.
139 Origin
140}
141
142string_newtype! {
143 /// Credential attach-time prefix, e.g. `"Bearer "` (trailing space
144 /// included). Pinned by AUTHZ-CRED-S01-output §2.
145 CredentialScheme
146}
147
148string_newtype! {
149 /// Opaque kind name for `CredentialKind::Other`. The framework's
150 /// compatibility logic treats `Other`-kinded credentials as untagged for
151 /// the purposes of `requires_credential` matching (per Tier B Q-FLOW-2).
152 CredentialKindName
153}
154
155string_newtype! {
156 /// Parameter name for in-RPC-parameter and first-frame attachment sites.
157 ParamName
158}
159
160string_newtype! {
161 /// Per-response credential identifier (the `<id>` in the
162 /// `{"$credential": "<id>"}` sentinel). Generated by the framework's
163 /// dispatch layer at envelope-build time; opaque to activations.
164 CredentialId
165}
166
167// ---------------------------------------------------------------------------
168// CredentialKind — closed enum (Tier B Q-FLOW-2).
169// ---------------------------------------------------------------------------
170
171/// What kind of credential this is. Tags storage decisions and drives the
172/// selection filter (`AUTHZ-CRED-CLI-3`). **Closed** for v1 — third crates
173/// cannot extend the enum; backends with bespoke schemes use the
174/// [`CredentialKind::Other`] escape valve.
175///
176/// The cost of going `Other` is loss of generic client integration — a method
177/// requiring `Bearer` will not auto-attach an `Other`-kinded credential.
178#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
179#[serde(tag = "kind", rename_all = "snake_case")]
180pub enum CredentialKind {
181 /// Static or short-lived bearer token (JWT, opaque token).
182 Bearer,
183 /// Cookie-shaped session credential (server may issue `Set-Cookie`).
184 Cookie,
185 /// OAuth/OIDC access token (paired with refresh; `refresh_via` populated).
186 OauthAccess,
187 /// OAuth/OIDC refresh token. Long-lived, used to mint new
188 /// [`CredentialKind::OauthAccess`] credentials.
189 OauthRefresh,
190 /// OIDC ID token (informational identity assertion; not for auth).
191 OidcId,
192 /// AWS STS credential set (composite: access_key_id + secret + token + exp).
193 AwsSts,
194 /// Macaroon-style capability token with caveats.
195 Macaroon,
196 /// Custom kind, for backends with bespoke schemes; stored opaquely.
197 Other {
198 /// Opaque name supplied by the backend.
199 name: CredentialKindName,
200 },
201}
202
203// ---------------------------------------------------------------------------
204// AttachmentSite — closed enum.
205// ---------------------------------------------------------------------------
206
207/// Where the credential is attached on the wire when sent on subsequent
208/// calls. The framework's client-side replay machinery reads this to build
209/// the outbound request.
210#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
211#[serde(tag = "site", rename_all = "snake_case")]
212pub enum AttachmentSite {
213 /// HTTP header. e.g. `Authorization: <scheme><value>`.
214 Header {
215 /// The header name (e.g. `authorization`).
216 name: HeaderName,
217 },
218 /// HTTP cookie. e.g. `Cookie: plexus_session=<value>`.
219 Cookie {
220 /// The cookie name (e.g. `plexus_session`).
221 name: CookieName,
222 },
223 /// First-frame WS auth: included as a parameter to a setup method.
224 FirstFrame {
225 /// The method that the client calls first on the WS connection.
226 setup_method: MethodPath,
227 /// The parameter on that setup call where the credential is passed.
228 param: ParamName,
229 },
230 /// In-RPC parameter: each call receives this credential as a named
231 /// parameter on the inbound activation. Used for backends without HTTP
232 /// cookie/header support (e.g., pure stdio).
233 InRpcParam {
234 /// The parameter name on every credential-requiring method.
235 param: ParamName,
236 },
237}
238
239// ---------------------------------------------------------------------------
240// CredentialIssuer.
241// ---------------------------------------------------------------------------
242
243/// Identity of the issuing party: the Origin the credential was issued from
244/// and the method that issued it. Drives the named-session auto-naming
245/// algorithm (AUTHZ-CRED-S01-output §5) and ties the credential to its
246/// lineage in audit.
247#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
248pub struct CredentialIssuer {
249 /// Backend Origin that issued this credential.
250 pub origin: Origin,
251 /// Method whose return type carries this credential field.
252 pub method: MethodPath,
253}
254
255impl CredentialIssuer {
256 /// Construct a `CredentialIssuer` value. Public — the issuer is not a
257 /// secret; activation code may construct one to pass into mint (the
258 /// `Credential<T>` itself remains sealed).
259 pub fn new(origin: Origin, method: MethodPath) -> Self {
260 Self { origin, method }
261 }
262}
263
264// ---------------------------------------------------------------------------
265// CredentialMetadata — the framework's contract surface.
266// ---------------------------------------------------------------------------
267
268/// What this credential is and how to attach it on subsequent calls.
269///
270/// Every metadata field is a typed newtype, never a bare string. The
271/// [`CredentialMetadata::sensitive`] field is always `true`; it exists on the
272/// struct so the metadata is the single source of truth for the redaction
273/// pipeline (AUTHZ-PRIVACY-1).
274///
275/// Metadata is **fixed at mint time**: a `Credential<T>` exposes its metadata
276/// via [`Credential::metadata`], but there is no mutable accessor.
277#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
278pub struct CredentialMetadata {
279 /// What kind of credential this is. Tags storage decisions and drives
280 /// selection.
281 pub kind: CredentialKind,
282
283 /// Where the credential is attached on the wire when sent on subsequent
284 /// calls.
285 pub attach_as: AttachmentSite,
286
287 /// Optional prefix prepended to the value at attach time (e.g.,
288 /// `"Bearer "` for `Authorization: Bearer <token>`). Stored in the
289 /// metadata so the client doesn't have to guess.
290 pub scheme: Option<CredentialScheme>,
291
292 /// Which scopes this credential authorizes. Empty set means "scope
293 /// decision is server-side; client doesn't filter."
294 pub scopes: Vec<Scope>,
295
296 /// Hard expiry of the credential value, if known at issue time. Used
297 /// for proactive refresh and for dropping stale stored credentials.
298 pub expires_at: Option<DateTime<Utc>>,
299
300 /// Optional refresh hint: if this credential expires, call this method
301 /// to obtain a fresh one. The named-session framework handles the swap;
302 /// activation code is uninvolved.
303 pub refresh_via: Option<MethodPath>,
304
305 /// Optional revocation hint: calling this method invalidates the
306 /// credential server-side.
307 pub revoke_via: Option<MethodPath>,
308
309 /// Identity of the issuing party.
310 pub issuer: CredentialIssuer,
311
312 /// Sensitivity marker for the redaction pipeline (AUTHZ-PRIVACY-1).
313 /// Always `true`; present for type-system uniformity so any code that
314 /// reads the metadata has a single source of truth.
315 pub sensitive: bool,
316}
317
318impl CredentialMetadata {
319 /// Construct a fresh `CredentialMetadata`. The `sensitive` flag is always
320 /// initialized to `true`; the field exists so callers reading metadata
321 /// can treat it as the single source of truth without consulting outside
322 /// state.
323 ///
324 /// Public — the metadata is not a secret. The seal is on the credential
325 /// VALUE, not on the metadata that describes it.
326 #[allow(clippy::too_many_arguments)]
327 pub fn new(
328 kind: CredentialKind,
329 attach_as: AttachmentSite,
330 scheme: Option<CredentialScheme>,
331 scopes: Vec<Scope>,
332 expires_at: Option<DateTime<Utc>>,
333 refresh_via: Option<MethodPath>,
334 revoke_via: Option<MethodPath>,
335 issuer: CredentialIssuer,
336 ) -> Self {
337 Self {
338 kind,
339 attach_as,
340 scheme,
341 scopes,
342 expires_at,
343 refresh_via,
344 revoke_via,
345 issuer,
346 sensitive: true,
347 }
348 }
349}
350
351// ---------------------------------------------------------------------------
352// Credential<T> — the sealed wrapper.
353// ---------------------------------------------------------------------------
354
355/// A sealed credential value. The inner `T` is constructable only via a
356/// [`CredentialMinter`] — itself only obtainable as a function parameter
357/// injected by the framework into credential-issuing methods.
358///
359/// Activation code can:
360/// - Construct via `minter.mint(payload, metadata)` (the framework witnesses
361/// the construction)
362/// - Read metadata via [`Credential::metadata`] (immutable reference)
363/// - Serialize via `serde_json::to_value(&cred)` — this produces ONLY the
364/// sentinel `{"$credential": "<id>"}`; the inner value never appears.
365///
366/// Activation code CANNOT:
367/// - Construct from raw bytes (no public `new` / `From<T>`)
368/// - Mutate the inner value (no `&mut` accessor)
369/// - Read the inner value via `serde_json::to_value` (the custom `Serialize`
370/// impl writes the sentinel, not the value)
371/// - Deserialize from raw JSON (`Deserialize` is intentionally absent)
372///
373/// # Sealing
374///
375/// `Credential<T>::new_sealed` is `pub(crate)`. Only [`CredentialMinter`]
376/// inside this crate calls it. The compile-fail tests in
377/// `tests/compile_fail/credential_*.rs` assert that external construction
378/// is rejected.
379#[derive(Debug, Clone)]
380pub struct Credential<T> {
381 /// The inner credential value. Private — the only public accessor is
382 /// the custom `Serialize` impl, which writes the sentinel by default
383 /// and routes the value into the sidecar only when the dispatch-side
384 /// capture toggle is active.
385 inner: T,
386
387 /// The metadata fixed at mint time.
388 metadata: CredentialMetadata,
389
390 /// Stable per-credential id used in the `{"$credential": "<id>"}`
391 /// sentinel. Generated at mint time; opaque to activations.
392 id: CredentialId,
393}
394
395impl<T> Credential<T> {
396 /// Mint a new `Credential<T>`. Crate-private — only [`CredentialMinter`]
397 /// inside `plexus-auth-core` calls this. External crates cannot reach it
398 /// (compile-fail tests assert this).
399 pub(crate) fn new_sealed(inner: T, metadata: CredentialMetadata, id: CredentialId) -> Self {
400 Self {
401 inner,
402 metadata,
403 id,
404 }
405 }
406
407 /// Immutable accessor for the metadata. There is no mutable counterpart —
408 /// metadata is fixed at mint time.
409 pub fn metadata(&self) -> &CredentialMetadata {
410 &self.metadata
411 }
412
413 /// The credential's stable id (used in the wire sentinel).
414 pub fn id(&self) -> &CredentialId {
415 &self.id
416 }
417
418 /// Framework-internal accessor for the inner value. `pub(crate)` to
419 /// `plexus-auth-core` so the dispatch-bridge code inside this crate can
420 /// extract the value for sidecar emission. Activation code has no
421 /// public path to this method.
422 ///
423 /// Per ticket §"Risks" #2, the cross-crate accessor for the dispatch
424 /// layer in plexus-core will be added as a follow-up via a sealed marker
425 /// trait the dispatch crate implements; this `pub(crate)` accessor is
426 /// the first step.
427 ///
428 /// `dead_code` is allowed because the dispatch-layer caller lands in
429 /// `AUTHZ-CRED-CORE-2`; the accessor is exercised by this crate's unit
430 /// tests but has no production consumer until then.
431 #[allow(dead_code)]
432 pub(crate) fn inner(&self) -> &T {
433 &self.inner
434 }
435}
436
437// `Default` is intentionally NOT derived. A default credential would be an
438// unsigned, anonymously-minted value with no metadata — a security footgun.
439// Compile-fail test `tests/compile_fail/credential_no_default.rs` asserts.
440
441// `Deserialize` is intentionally NOT derived. Raw JSON must never fabricate
442// a sealed credential value; the only path to one is `CredentialMinter::mint`.
443
444// ---------------------------------------------------------------------------
445// Audit-side projection.
446// ---------------------------------------------------------------------------
447
448impl<T> Credential<T> {
449 /// Project to the metadata for audit-record emission. Read-only; the
450 /// returned reference is the metadata exactly as fixed at mint time.
451 ///
452 /// Equivalent to [`Self::metadata`], named separately to make the
453 /// audit-side projection grep-discoverable. Per
454 /// AUTHZ-CRED-S01-output §8, the audit pipeline records
455 /// `credentials_issued: Vec<CredentialMetadata>` — this is the projection
456 /// that produces it. The inner credential value is never included.
457 pub fn audit_projection(&self) -> &CredentialMetadata {
458 &self.metadata
459 }
460}
461
462// ---------------------------------------------------------------------------
463// Sentinel-emitting Serialize impl (Tier B Q-WIRE-3).
464// ---------------------------------------------------------------------------
465
466/// The shape of a credential captured into the dispatch-side sidecar.
467///
468/// `value` holds the JSON-encoded inner credential payload; `metadata` rides
469/// alongside; `id` is the stable per-credential identifier matching the
470/// `{"$credential": "<id>"}` sentinel emitted inline in the body. Only
471/// emitted into the sidecar when the dispatch capture toggle is active;
472/// otherwise the credential's `Serialize` impl emits only the sentinel.
473///
474/// The `id` field carries the same `CredentialId` that the sidecar's
475/// internal `HashMap` uses as its key. It is duplicated onto the
476/// `CapturedCredential` itself so the public scoped-callback API
477/// ([`run_with_credential_capture`]) can return a `Vec<CapturedCredential>`
478/// that is self-describing — callers do not need to track the id
479/// separately.
480#[derive(Debug, Clone)]
481pub struct CapturedCredential {
482 /// Stable per-credential id matching the `{"$credential": "<id>"}`
483 /// sentinel emitted inline.
484 pub id: CredentialId,
485 /// JSON-encoded inner value. Stored as `serde_json::Value` so the
486 /// dispatch wrapper can re-emit it in the envelope without re-serializing
487 /// the typed value through a second pass.
488 pub value: serde_json::Value,
489 /// The metadata as fixed at mint time.
490 pub metadata: CredentialMetadata,
491}
492
493/// Sidecar collector populated by the credential `Serialize` impl while a
494/// [`DispatchCaptureGuard`] is active. The dispatch layer reads the
495/// collected map after serialization completes and emits it as the
496/// `_credentials` envelope key (AUTHZ-CRED-S01-output §3, Q-WIRE-1).
497#[derive(Debug, Default)]
498pub struct DispatchSidecar {
499 /// Map of credential id → captured value+metadata.
500 map: HashMap<CredentialId, CapturedCredential>,
501}
502
503impl DispatchSidecar {
504 /// Construct an empty sidecar.
505 pub fn new() -> Self {
506 Self::default()
507 }
508
509 /// Drain the collected entries. The dispatch layer calls this after the
510 /// outer `Serialize` pass completes.
511 pub fn drain(&mut self) -> HashMap<CredentialId, CapturedCredential> {
512 std::mem::take(&mut self.map)
513 }
514
515 /// Whether the sidecar has captured any credential during the active
516 /// dispatch pass.
517 pub fn is_empty(&self) -> bool {
518 self.map.is_empty()
519 }
520
521 /// Number of captured credentials.
522 pub fn len(&self) -> usize {
523 self.map.len()
524 }
525}
526
527thread_local! {
528 /// While `Some(sidecar)`, the credential `Serialize` impl writes the
529 /// inner value into the sidecar AND emits the sentinel inline. While
530 /// `None` (the default — application code, audit writers, naive
531 /// `serde_json::to_value` calls), only the sentinel is emitted; the
532 /// value never appears in the produced JSON.
533 ///
534 /// There is no public setter for activation code. The only path to
535 /// install a sidecar is via [`DispatchCaptureGuard::install`], whose
536 /// constructor is `pub(crate)` and reachable only from the
537 /// dispatch-bridge code in this crate.
538 static DISPATCH_SIDECAR: RefCell<Option<DispatchSidecar>> = const { RefCell::new(None) };
539}
540
541/// Counter for auto-generated credential ids. The framework's dispatch
542/// layer is the only caller; activations never see this directly.
543static CREDENTIAL_ID_COUNTER: AtomicU64 = AtomicU64::new(0);
544
545fn next_credential_id() -> CredentialId {
546 let n = CREDENTIAL_ID_COUNTER.fetch_add(1, Ordering::Relaxed);
547 CredentialId::new(format!("cred_{n}"))
548}
549
550/// RAII guard that activates dispatch-side capture for the lifetime of the
551/// guard. The constructor is `pub(crate)`: activation code cannot create
552/// one. The dispatch layer (`plexus-core` / `plexus-transport`, via a
553/// `pub(crate)`-exposed helper in this crate) is the only caller.
554///
555/// On `Drop`, the guard clears the thread-local even if the wrapped
556/// operation panicked, so a mid-serialization panic cannot leak the value
557/// onto a subsequent reentrant call.
558pub struct DispatchCaptureGuard {
559 /// The previous sidecar (if any) is restored on drop, supporting nested
560 /// dispatch passes (rare but well-defined).
561 previous: Option<DispatchSidecar>,
562}
563
564impl DispatchCaptureGuard {
565 /// Install a fresh sidecar for the lifetime of the returned guard.
566 /// `pub(crate)` — activation code has no path to construct one.
567 ///
568 /// `dead_code` is allowed because the dispatch-layer caller lands in
569 /// `AUTHZ-CRED-CORE-2`; the guard must exist now so the compile-fail
570 /// tests can assert external code cannot reach it.
571 #[allow(dead_code)]
572 pub(crate) fn install() -> Self {
573 let previous = DISPATCH_SIDECAR.with(|cell| {
574 let prev = cell.borrow_mut().take();
575 *cell.borrow_mut() = Some(DispatchSidecar::new());
576 prev
577 });
578 Self { previous }
579 }
580
581 /// Drain the captured credentials from the active sidecar. Returns
582 /// `None` if (unexpectedly) no sidecar is installed. `pub(crate)` for
583 /// the same reason as [`Self::install`].
584 #[allow(dead_code)]
585 pub(crate) fn drain(&self) -> Option<HashMap<CredentialId, CapturedCredential>> {
586 DISPATCH_SIDECAR.with(|cell| cell.borrow_mut().as_mut().map(|s| s.drain()))
587 }
588}
589
590impl Drop for DispatchCaptureGuard {
591 fn drop(&mut self) {
592 // Restore the previous sidecar (or clear). Done unconditionally so a
593 // panic mid-serialization cannot leave the toggle dangling.
594 let prev = self.previous.take();
595 DISPATCH_SIDECAR.with(|cell| {
596 *cell.borrow_mut() = prev;
597 });
598 }
599}
600
601/// Convenience helper: run `f` with a fresh dispatch sidecar installed, then
602/// return both `f`'s output and the captured credentials.
603///
604/// `pub(crate)` — same reason as [`DispatchCaptureGuard`].
605///
606/// # Panic-safety
607///
608/// If `f` panics, the guard's `Drop` impl clears the thread-local before
609/// the panic unwinds past this function, so a subsequent reentrant call
610/// observes a clean state. See the unit test
611/// `dispatch_capture_resets_on_panic` for the assertion.
612#[allow(dead_code)]
613pub(crate) fn with_dispatch_capture<F, R>(f: F) -> (R, HashMap<CredentialId, CapturedCredential>)
614where
615 F: FnOnce() -> R,
616{
617 let guard = DispatchCaptureGuard::install();
618 let out = f();
619 let captured = guard.drain().unwrap_or_default();
620 drop(guard);
621 (out, captured)
622}
623
624/// Public scoped-callback entry point for dispatch-time credential capture.
625///
626/// Mirrors the AUTHLANG-3 [`crate::auth::AuthContext::with_callee_context`]
627/// pattern: the framework operation (installing the
628/// [`DispatchCaptureGuard`]) is exposed to other crates ONLY for the
629/// duration of a caller-supplied closure. External callers cannot retain
630/// the guard past the function return — the seal property is "the guard's
631/// lifetime is bounded by the function call."
632///
633/// # Behavior
634///
635/// 1. Installs a fresh [`DispatchCaptureGuard`] on the current thread.
636/// 2. Runs `f`.
637/// 3. Drains the captured credentials from the guard's sidecar.
638/// 4. Drops the guard (restoring any previously-installed sidecar).
639/// 5. Returns `(f_output, Vec<CapturedCredential>)`.
640///
641/// Each returned [`CapturedCredential`] carries its own [`CredentialId`]
642/// matching the `{"$credential": "<id>"}` sentinel emitted inline in the
643/// serialized body, so the caller can correlate sidecar entries with
644/// sentinels without tracking a side map.
645///
646/// # Nested invocations
647///
648/// Nested calls to `run_with_credential_capture` install a fresh inner
649/// sidecar; inner credentials drain to the inner caller and the outer
650/// continues with its own (still-pending) sidecar after the inner
651/// returns. The two sidecars never interleave — the [`DispatchCaptureGuard`]'s
652/// `Drop` impl restores the previously-installed sidecar.
653///
654/// # Panic safety
655///
656/// If `f` panics, the guard's `Drop` impl clears the thread-local on
657/// unwind so a subsequent reentrant call observes a clean state. The
658/// partial sidecar is not delivered to the caller — capture is
659/// all-or-nothing per envelope by design.
660///
661/// # Sync-only (v1)
662///
663/// `f` is a synchronous `FnOnce`. If `f` returns a `Future`, the future
664/// executes AFTER the guard is dropped — credentials minted inside an
665/// awaited continuation are not captured. CRED-CORE-2's dispatch wrapper
666/// serializes synchronously into a buffer, so the synchronous shape
667/// suffices for v1. An async-aware variant (`run_with_credential_capture_async`)
668/// can land in a follow-up if a dispatch path needs awaiting inside the
669/// capture scope.
670///
671/// # Why scoped-callback, not a `pub install`?
672///
673/// Per AUTHZ-0 §"Sealed-type pattern": the policy proposes; the framework
674/// disposes. Exposing a raw `pub fn install() -> DispatchCaptureGuard`
675/// would let activation code retain a guard for an unbounded lifetime
676/// and observe credentials minted by unrelated framework code on the
677/// same thread. The scoped-callback shape pins the guard's lifetime to
678/// the closure, which is the actual seal property we need.
679///
680/// See `plans/AUTHZ/AUTHZ-CRED-CORE-1B.md` for the design rationale.
681pub fn run_with_credential_capture<F, R>(f: F) -> (R, Vec<CapturedCredential>)
682where
683 F: FnOnce() -> R,
684{
685 let guard = DispatchCaptureGuard::install();
686 let out = f();
687 let captured_map = guard.drain().unwrap_or_default();
688 drop(guard);
689 // Sort by id for deterministic ordering. Credential ids are assigned
690 // by an atomic counter in mint order, so this is also mint order.
691 let mut captured: Vec<CapturedCredential> = captured_map.into_values().collect();
692 captured.sort_by(|a, b| a.id.as_str().cmp(b.id.as_str()));
693 (out, captured)
694}
695
696impl<T> Serialize for Credential<T>
697where
698 T: Serialize,
699{
700 /// Emits the sentinel `{"$credential": "<id>"}` always.
701 ///
702 /// If a dispatch-capture guard is active on the current thread, the
703 /// inner value is ALSO captured into the sidecar (keyed by id) so the
704 /// dispatch wrapper can emit it under the envelope's `_credentials`
705 /// key. Application code that calls `serde_json::to_value(&credential)`
706 /// without a guard sees only the sentinel — the inner value never
707 /// appears in the produced JSON.
708 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
709 where
710 S: Serializer,
711 {
712 // Capture into the sidecar if active. We do this BEFORE writing the
713 // sentinel so a serialization failure mid-write does not leave a
714 // half-captured value visible to the dispatch wrapper.
715 //
716 // Serializing the inner value to a `serde_json::Value` here is a
717 // necessary step because the outer Serializer may not be JSON
718 // (e.g., bincode used by an audit sink, MessagePack); the sidecar
719 // is JSON-only by design (the envelope is JSON-RPC), so we lift
720 // the inner value to JSON for sidecar storage regardless of the
721 // outer serializer's target format.
722 DISPATCH_SIDECAR.with(|cell| {
723 let mut borrow = cell.borrow_mut();
724 if let Some(sidecar) = borrow.as_mut() {
725 // Failure to JSON-serialize the inner value is non-fatal for
726 // the sentinel emission — the dispatch wrapper sees the
727 // missing entry and surfaces a structured error later. We
728 // store a `null` placeholder so the id is reserved even on
729 // serialization failure (rare; T is constrained Serialize).
730 let value = serde_json::to_value(&self.inner).unwrap_or(serde_json::Value::Null);
731 sidecar.map.insert(
732 self.id.clone(),
733 CapturedCredential {
734 id: self.id.clone(),
735 value,
736 metadata: self.metadata.clone(),
737 },
738 );
739 }
740 });
741
742 // Emit the sentinel. Always — whether or not a guard is active, the
743 // outer JSON document contains only `{"$credential": "<id>"}` where
744 // the credential lived. The inner value never inlines here.
745 let mut map = serializer.serialize_map(Some(1))?;
746 map.serialize_entry("$credential", self.id.as_str())?;
747 map.end()
748 }
749}
750
751// ---------------------------------------------------------------------------
752// CredentialMinter — the framework's injected service.
753// ---------------------------------------------------------------------------
754
755/// The framework-issued service that mints sealed [`Credential<T>`] values.
756///
757/// `CredentialMinter`'s constructor is `pub(crate)` — only `plexus-auth-core`
758/// can produce one. The framework's dispatch layer (per `AUTHZ-CRED-CORE-2`)
759/// injects a `&CredentialMinter` into credential-issuing methods; activation
760/// code receives the reference but cannot construct its own minter, nor
761/// extend the seal by aliasing the type.
762///
763/// The minter ties construction to the originating method invocation so
764/// audit can attribute the issuance. v1 carries the issuer hint as a stored
765/// field on the minter so [`CredentialMinter::mint_with_issuer`] does not
766/// require it as a separate argument; richer audit context (call id,
767/// invocation chain position) is added in `AUTHZ-CRED-CORE-2` when the
768/// dispatch-layer wiring lands.
769#[derive(Debug, Clone)]
770pub struct CredentialMinter {
771 /// The issuer that subsequent mints stamp onto every credential's
772 /// metadata when [`CredentialMinter::mint_with_issuer`] is used (the
773 /// variant that takes pre-built metadata bypasses this field).
774 default_issuer: CredentialIssuer,
775}
776
777impl CredentialMinter {
778 /// Construct a minter scoped to a particular issuer. Crate-private —
779 /// only the framework's dispatch layer (in a follow-up ticket) calls
780 /// this; activation code receives a reference, never constructs one.
781 ///
782 /// `dead_code` is allowed because the dispatch-layer caller lands in
783 /// `AUTHZ-CRED-CORE-2`. The constructor must exist now so the
784 /// compile-fail tests have something to point at.
785 #[allow(dead_code)]
786 pub(crate) fn new_sealed(default_issuer: CredentialIssuer) -> Self {
787 Self { default_issuer }
788 }
789
790 /// Test-only constructor exposed under the `test-support` feature.
791 ///
792 /// Production builds never see this method — the `test-support`
793 /// feature is documented as a `dev-dependency`-only flag (see
794 /// `Cargo.toml`). The `#[doc(hidden)]` attribute keeps it out of
795 /// rustdoc so the public API surface still reads as
796 /// "no constructor reachable from outside the crate."
797 ///
798 /// Added by AUTHZ-CRED-CORE-1B so plexus-core can write end-to-end
799 /// tests of the dispatch-time credential interception path that
800 /// require minting a real `Credential<T>`.
801 #[cfg(feature = "test-support")]
802 #[doc(hidden)]
803 pub fn new_for_test(default_issuer: CredentialIssuer) -> Self {
804 Self { default_issuer }
805 }
806
807 /// The default issuer the minter stamps onto credentials minted via
808 /// [`CredentialMinter::mint_with_issuer`]. Useful for audit hooks.
809 pub fn issuer(&self) -> &CredentialIssuer {
810 &self.default_issuer
811 }
812
813 /// Mint a sealed [`Credential<T>`] from a raw payload and pre-built
814 /// metadata.
815 ///
816 /// This is the **only** public path from a raw `T` to a sealed
817 /// `Credential<T>`. Activation code calls it via the framework-injected
818 /// `&CredentialMinter` reference.
819 pub fn mint<T>(&self, payload: T, metadata: CredentialMetadata) -> Credential<T> {
820 let id = next_credential_id();
821 Credential::new_sealed(payload, metadata, id)
822 }
823
824 /// Mint a sealed [`Credential<T>`] from a raw payload, populating the
825 /// metadata's `issuer` field from the minter's [`Self::issuer`]. The
826 /// caller supplies everything else.
827 #[allow(clippy::too_many_arguments)]
828 pub fn mint_with_issuer<T>(
829 &self,
830 payload: T,
831 kind: CredentialKind,
832 attach_as: AttachmentSite,
833 scheme: Option<CredentialScheme>,
834 scopes: Vec<Scope>,
835 expires_at: Option<DateTime<Utc>>,
836 refresh_via: Option<MethodPath>,
837 revoke_via: Option<MethodPath>,
838 ) -> Credential<T> {
839 let metadata = CredentialMetadata::new(
840 kind,
841 attach_as,
842 scheme,
843 scopes,
844 expires_at,
845 refresh_via,
846 revoke_via,
847 self.default_issuer.clone(),
848 );
849 self.mint(payload, metadata)
850 }
851}
852
853// ---------------------------------------------------------------------------
854// CredentialFieldMarker — registry entry emitted by `#[derive(Credentials)]`.
855// ---------------------------------------------------------------------------
856
857/// A single registry entry describing one `#[credential(...)]`-annotated
858/// field on a credential-bearing type. The
859/// `#[derive(plexus_macros::Credentials)]` macro emits a per-type free
860/// function `__plexus_credential_marker_for_<TypeIdent>() ->
861/// Vec<CredentialFieldMarker>` returning these entries in stable
862/// declaration order.
863///
864/// # Shape
865///
866/// The fields below mirror the macro's emission contract (see
867/// `plexus-macros/src/credential.rs::emit_field_marker_initializer`):
868///
869/// - [`Self::variant`] — `Some(variant_name)` for enums, `None` for structs.
870/// - [`Self::field`] — the field's identifier, e.g. `"session"`. For tuple
871/// fields, the zero-based index as a string ("0", "1", ...).
872/// - [`Self::kind`] — credential kind (`Bearer`, `Cookie`, `OauthAccess`, ...).
873/// - [`Self::attach_as`] — where the credential is attached on the wire.
874/// - [`Self::scheme`] — optional attach-time prefix (e.g. `"Bearer "`).
875/// - [`Self::scopes`] — declared scopes for the credential.
876/// - [`Self::refresh_via`] — optional method to refresh an expired credential.
877/// - [`Self::revoke_via`] — optional method to revoke the credential.
878///
879/// # Aggregating into `CredentialMetadata`
880///
881/// The marker carries the credential's *static* metadata as declared at the
882/// field. The full [`CredentialMetadata`] form additionally requires
883/// `expires_at` (known only at mint time) and `issuer` (known only at runtime
884/// from the originating method). [`Self::to_metadata`] composes a partial
885/// `CredentialMetadata` value, leaving `expires_at` and `issuer` to be
886/// supplied by the dispatch layer.
887///
888/// # Consumers
889///
890/// - `AUTHZ-CRED-CORE-3` (schema-build credential reflection): reads markers
891/// to project credential metadata into the IR / `MethodSchema`.
892/// - Backends instrumenting the registry for diagnostic purposes.
893///
894/// # `parent_type_id` (deferred)
895///
896/// The ticket described a `parent_type_id: std::any::TypeId` field for
897/// `TypeId`-keyed registry lookup. The macro's v1 emission keys the registry
898/// by *function name* (`__plexus_credential_marker_for_<TypeIdent>`), not by
899/// `TypeId`; adding `parent_type_id` to this struct would require either
900/// (a) a `T: 'static` bound at the derive site, threading it through the
901/// macro, or (b) materializing the `TypeId` in the emitted function and
902/// stamping it onto every entry. Both are additive; neither is needed by the
903/// current consumer set (`AUTHZ-CRED-CORE-3`'s schema projection works from
904/// the function-name surface). Tracked as the follow-up note in
905/// `AUTHZ-CRED-MACRO-1-RUN-NOTES.md`.
906///
907/// Similarly, a `field_index: u16` is implicit in the slice index returned
908/// from the registry function; callers needing index can `.iter().enumerate()`.
909///
910/// # `TypeId` cross-compilation-unit caveat
911///
912/// When `parent_type_id` lands in a follow-up: `std::any::TypeId` is
913/// documented as stable *within a single compiled binary*, not across
914/// independently compiled libraries or Rust versions. The v1 use case
915/// (one binary registering markers and reading them in the same binary) is
916/// fine; cross-binary registry sharing would require a hashed name or a
917/// stable type-id scheme.
918#[derive(Debug, Clone)]
919pub struct CredentialFieldMarker {
920 /// For enums: the variant name where the field lives. For structs:
921 /// `None`.
922 pub variant: Option<&'static str>,
923
924 /// The field's identifier (e.g. `"session"`). For tuple fields, the
925 /// zero-based index rendered as a string.
926 pub field: &'static str,
927
928 /// Credential kind (Bearer, Cookie, OauthAccess, ...).
929 pub kind: CredentialKind,
930
931 /// Where on the wire the credential is attached when sent on subsequent
932 /// calls.
933 pub attach_as: AttachmentSite,
934
935 /// Optional attach-time prefix (e.g. `"Bearer "` for
936 /// `Authorization: Bearer <token>`).
937 pub scheme: Option<CredentialScheme>,
938
939 /// Declared scopes for the credential. Empty if not specified.
940 pub scopes: Vec<Scope>,
941
942 /// Optional method-path the framework calls to refresh this credential
943 /// when it expires.
944 pub refresh_via: Option<MethodPath>,
945
946 /// Optional method-path the framework calls to revoke this credential
947 /// server-side.
948 pub revoke_via: Option<MethodPath>,
949}
950
951impl CredentialFieldMarker {
952 /// Construct a marker. Public — registry entries are user-facing
953 /// metadata, not a sealed primitive. The macro emits struct-literal
954 /// initializers; this constructor exists for backend code that builds
955 /// markers programmatically.
956 #[allow(clippy::too_many_arguments)]
957 pub fn new(
958 variant: Option<&'static str>,
959 field: &'static str,
960 kind: CredentialKind,
961 attach_as: AttachmentSite,
962 scheme: Option<CredentialScheme>,
963 scopes: Vec<Scope>,
964 refresh_via: Option<MethodPath>,
965 revoke_via: Option<MethodPath>,
966 ) -> Self {
967 Self {
968 variant,
969 field,
970 kind,
971 attach_as,
972 scheme,
973 scopes,
974 refresh_via,
975 revoke_via,
976 }
977 }
978
979 /// Compose a [`CredentialMetadata`] from this marker plus the runtime-
980 /// supplied pieces (`expires_at` from mint time, `issuer` from the
981 /// invoking method's context).
982 ///
983 /// The marker carries the *static* metadata declared at the field; the
984 /// dispatch layer supplies the dynamic pieces. Together they fully
985 /// reconstruct the metadata that [`CredentialMinter::mint`] would mint.
986 pub fn to_metadata(
987 &self,
988 expires_at: Option<chrono::DateTime<chrono::Utc>>,
989 issuer: CredentialIssuer,
990 ) -> CredentialMetadata {
991 CredentialMetadata::new(
992 self.kind.clone(),
993 self.attach_as.clone(),
994 self.scheme.clone(),
995 self.scopes.clone(),
996 expires_at,
997 self.refresh_via.clone(),
998 self.revoke_via.clone(),
999 issuer,
1000 )
1001 }
1002}
1003
1004// ---------------------------------------------------------------------------
1005// CredentialsRegistry — autoref-specialized marker-slice dispatch wiring the
1006// macro-emitted credential registry to consumer codegen (AUTHZ-CRED-MACRO-3).
1007// ---------------------------------------------------------------------------
1008
1009/// Trait that types deriving `#[derive(plexus_macros::Credentials)]` opt into
1010/// by providing their static credential-marker slice.
1011///
1012/// Consumers do not implement this trait by hand; the
1013/// `#[derive(plexus_macros::Credentials)]` macro emits the impl with the
1014/// populated `credential_markers()` body. Types that do NOT derive
1015/// `Credentials` do not implement this trait at all — instead, the
1016/// `#[plexus_macros::activation]` call site dispatches to the blanket
1017/// fallback [`CredentialsRegistryFallback`] which returns an empty slice.
1018///
1019/// # Why a separate marker trait?
1020///
1021/// The autoref-specialization trick (see [`CredentialsRegistryProbe`])
1022/// uses TWO impls on `CredentialsRegistryProbe<T>`:
1023///
1024/// 1. An inherent impl gated on `T: HasCredentialMarkers` — populated path.
1025/// 2. A trait impl on `&CredentialsRegistryProbe<T>` — empty fallback path.
1026///
1027/// Inherent impls are constrained to the type's home crate (orphan rule
1028/// E0116); the user's `#[derive(Credentials)]` cannot write
1029/// `impl CredentialsRegistryProbe<UserType>` because `CredentialsRegistryProbe`
1030/// lives in `plexus_auth_core`. The marker trait sidesteps this: the user's
1031/// derive emits a normal trait impl `impl HasCredentialMarkers for UserType`
1032/// (orphan-allowed), and `plexus_auth_core`'s generic inherent impl
1033/// `impl<T: HasCredentialMarkers> CredentialsRegistryProbe<T>` does the
1034/// dispatch.
1035pub trait HasCredentialMarkers {
1036 /// Return the static slice of credential-field markers for this type.
1037 /// Emitted by the `#[derive(Credentials)]` macro; not implemented by
1038 /// hand.
1039 fn credential_markers() -> &'static [CredentialFieldMarker];
1040}
1041
1042/// Type-erased "probe" used by the autoref-specialization trick to dispatch
1043/// to either the populated marker slice (when `T: HasCredentialMarkers`) or
1044/// the empty fallback (otherwise).
1045///
1046/// Consumers do not construct this directly; the `#[plexus_macros::method]`
1047/// codegen emits the call site
1048/// `(&CredentialsRegistryProbe::<ReturnType>::new()).marker_slice()`. Rust's
1049/// method-resolution rules prefer inherent methods over trait methods, so
1050/// the inherent `marker_slice` on `CredentialsRegistryProbe<T>` (gated on
1051/// `T: HasCredentialMarkers`) wins when the return type derives
1052/// `Credentials`. Otherwise autoref kicks in and the trait method on
1053/// `&CredentialsRegistryProbe<T>` provides the empty-slice fallback.
1054///
1055/// # Why autoref-specialization?
1056///
1057/// The straightforward shape — a trait `CredentialsRegistry` with a default
1058/// `marker_slice()` method — requires either (a) a blanket `impl<T>` (which
1059/// then cannot be overridden by the derive without nightly specialization),
1060/// or (b) every consumer type explicitly implementing the trait (which would
1061/// be a breaking change for the entire workspace). The probe-with-autoref
1062/// pattern preserves stable Rust, requires zero opt-in from non-deriving
1063/// types, and lets the macro emit a normal trait impl (orphan-allowed) on
1064/// the user's type to drive the specialized inherent path.
1065///
1066/// See `plans/AUTHZ/AUTHZ-CRED-MACRO-3-RUN-NOTES.md` for the design rationale
1067/// and alternatives considered.
1068#[doc(hidden)]
1069pub struct CredentialsRegistryProbe<T: ?Sized>(::core::marker::PhantomData<fn() -> T>);
1070
1071impl<T: ?Sized> CredentialsRegistryProbe<T> {
1072 /// Construct a fresh probe value. Free of runtime cost — the inner
1073 /// `PhantomData` carries no data, only the type parameter.
1074 #[doc(hidden)]
1075 pub const fn new() -> Self {
1076 Self(::core::marker::PhantomData)
1077 }
1078}
1079
1080impl<T: ?Sized> Default for CredentialsRegistryProbe<T> {
1081 fn default() -> Self {
1082 Self::new()
1083 }
1084}
1085
1086/// Specialized inherent path: when `T: HasCredentialMarkers`, the probe
1087/// exposes the populated marker slice via `T::credential_markers()`.
1088/// Rust prefers this inherent method over the trait method from the
1089/// fallback impl, so types that derive `Credentials` always hit this path.
1090impl<T: HasCredentialMarkers + ?Sized> CredentialsRegistryProbe<T> {
1091 /// Forward to `T::credential_markers()`. The probe call site never
1092 /// names this method directly; macro-emitted code at the activation
1093 /// site calls `(&probe).marker_slice()`, and autoref-resolution picks
1094 /// this inherent method over the trait fallback.
1095 #[doc(hidden)]
1096 pub fn marker_slice(&self) -> &'static [CredentialFieldMarker] {
1097 T::credential_markers()
1098 }
1099}
1100
1101/// Fallback path: when `T` does NOT implement `HasCredentialMarkers`, the
1102/// inherent `marker_slice` doesn't apply, autoref kicks in, and the
1103/// reference-typed trait impl below supplies the empty-slice default.
1104///
1105/// Consumers do not name this trait; the `#[plexus_macros::activation]`
1106/// codegen brings it into scope via a `use` declaration inside the
1107/// macro-generated `compute_method_schemas` body, so the same call site
1108/// (`(&probe).marker_slice()`) works whether the return type derives
1109/// `Credentials` or not.
1110pub trait CredentialsRegistryFallback {
1111 /// Empty-slice fallback. Pre-AUTHZ-CRED-MACRO-3 behavior — every
1112 /// method's `MethodSchema.credentials` was `[]`; the fallback preserves
1113 /// that for types that don't derive `Credentials`.
1114 fn marker_slice(&self) -> &'static [CredentialFieldMarker] {
1115 &[]
1116 }
1117}
1118
1119// Blanket impl on `&CredentialsRegistryProbe<T>` — autoref lands here for
1120// types that don't have the `HasCredentialMarkers` inherent path.
1121impl<T: ?Sized> CredentialsRegistryFallback for &CredentialsRegistryProbe<T> {}
1122
1123/// Deprecated alias kept while consumers migrate to the new
1124/// [`CredentialsRegistryFallback`] name.
1125///
1126/// The original AUTHZ-CRED-MACRO-3 trait shape was named
1127/// `CredentialsRegistry`; renaming it to
1128/// [`CredentialsRegistryFallback`] makes the trait's role at the call site
1129/// (fallback path only) explicit. This alias preserves call sites that
1130/// already wrote `use plexus_auth_core::CredentialsRegistry`.
1131#[deprecated(
1132 since = "0.1.0",
1133 note = "Use `CredentialsRegistryFallback` instead (renamed for clarity)."
1134)]
1135pub trait CredentialsRegistry: CredentialsRegistryFallback {}
1136
1137#[allow(deprecated)]
1138impl<T: CredentialsRegistryFallback + ?Sized> CredentialsRegistry for T {}
1139
1140// ---------------------------------------------------------------------------
1141// Unit tests.
1142// ---------------------------------------------------------------------------
1143
1144#[cfg(test)]
1145mod tests {
1146 use super::*;
1147
1148 fn sample_issuer() -> CredentialIssuer {
1149 CredentialIssuer::new(
1150 Origin::new("ws://localhost:4444"),
1151 MethodPath::try_new("auth.login").unwrap(),
1152 )
1153 }
1154
1155 fn sample_metadata() -> CredentialMetadata {
1156 CredentialMetadata::new(
1157 CredentialKind::Bearer,
1158 AttachmentSite::Header {
1159 name: HeaderName::try_new("authorization").unwrap(),
1160 },
1161 Some(CredentialScheme::new("Bearer ")),
1162 vec![Scope::new("cone.send_message")],
1163 None,
1164 Some(MethodPath::try_new("auth.refresh").unwrap()),
1165 Some(MethodPath::try_new("auth.logout").unwrap()),
1166 sample_issuer(),
1167 )
1168 }
1169
1170 #[test]
1171 fn minter_mints_credential_via_internal_api() {
1172 // Acceptance criterion 2 + the ticket's "CredentialMinter mints
1173 // successfully via internal API" test.
1174 let minter = CredentialMinter::new_sealed(sample_issuer());
1175 let cred: Credential<String> =
1176 minter.mint("eyJhbGciOiJIUzI1NiIs...token".to_string(), sample_metadata());
1177 // The credential carries the metadata verbatim.
1178 assert_eq!(cred.metadata().kind, CredentialKind::Bearer);
1179 // The audit projection equals the metadata.
1180 assert_eq!(cred.audit_projection(), cred.metadata());
1181 // The inner accessor is reachable from inside the crate (this test
1182 // lives inside `plexus-auth-core`).
1183 assert_eq!(cred.inner(), "eyJhbGciOiJIUzI1NiIs...token");
1184 }
1185
1186 #[test]
1187 fn metadata_sensitive_is_always_true() {
1188 let m = sample_metadata();
1189 assert!(m.sensitive, "sensitive must be initialized to true");
1190 }
1191
1192 #[test]
1193 fn credential_id_is_unique_per_mint() {
1194 let minter = CredentialMinter::new_sealed(sample_issuer());
1195 let c1: Credential<String> = minter.mint("a".to_string(), sample_metadata());
1196 let c2: Credential<String> = minter.mint("b".to_string(), sample_metadata());
1197 assert_ne!(c1.id(), c2.id());
1198 }
1199
1200 #[test]
1201 fn serialize_outside_dispatch_emits_only_sentinel() {
1202 // Acceptance criterion 9: serde_json::to_value(&credential) outside
1203 // dispatch context produces an object containing the $credential
1204 // sentinel and NO inner value.
1205 let minter = CredentialMinter::new_sealed(sample_issuer());
1206 let cred: Credential<String> =
1207 minter.mint("super-secret-token".to_string(), sample_metadata());
1208 let v = serde_json::to_value(&cred).expect("serialize");
1209 // The serialized form is exactly `{"$credential": "<id>"}`.
1210 let obj = v.as_object().expect("object");
1211 assert_eq!(obj.len(), 1, "sentinel is the only key");
1212 let id = obj
1213 .get("$credential")
1214 .and_then(|v| v.as_str())
1215 .expect("$credential id is a string");
1216 assert_eq!(id, cred.id().as_str());
1217 // The inner value never appears in the serialized form.
1218 let serialized = serde_json::to_string(&cred).expect("string");
1219 assert!(
1220 !serialized.contains("super-secret-token"),
1221 "inner value must not appear in default serialization, got: {serialized}"
1222 );
1223 }
1224
1225 #[test]
1226 fn serialize_inside_dispatch_captures_value_into_sidecar() {
1227 // Acceptance criterion 10: when the dispatch-side thread-local is
1228 // active under a scoped guard, the inner value is captured into a
1229 // sidecar AND the sentinel is emitted inline. When inactive, only
1230 // the sentinel is emitted.
1231 let minter = CredentialMinter::new_sealed(sample_issuer());
1232 let cred: Credential<String> =
1233 minter.mint("dispatch-secret".to_string(), sample_metadata());
1234
1235 let (json_value, captured) = with_dispatch_capture(|| {
1236 serde_json::to_value(&cred).expect("serialize under guard")
1237 });
1238
1239 // The outer JSON still contains only the sentinel.
1240 let obj = json_value.as_object().expect("object");
1241 assert!(obj.contains_key("$credential"));
1242 assert!(!obj.contains_key("inner"));
1243
1244 // The sidecar captured the inner value keyed by id.
1245 assert_eq!(captured.len(), 1);
1246 let entry = captured.get(cred.id()).expect("captured by id");
1247 assert_eq!(
1248 entry.value,
1249 serde_json::Value::String("dispatch-secret".into())
1250 );
1251 assert_eq!(entry.metadata.kind, CredentialKind::Bearer);
1252
1253 // After the guard drops, a subsequent serialization is back to
1254 // sentinel-only (no value capture).
1255 let plain = serde_json::to_value(&cred).expect("serialize after guard");
1256 assert!(plain
1257 .as_object()
1258 .expect("object")
1259 .contains_key("$credential"));
1260 // And the inner value does not appear in the plain serialization.
1261 let plain_str = serde_json::to_string(&plain).unwrap();
1262 assert!(!plain_str.contains("dispatch-secret"));
1263 }
1264
1265 #[test]
1266 fn dispatch_capture_resets_on_panic() {
1267 // Acceptance criterion 11: the toggle's guard is reset on drop even
1268 // when the wrapped operation panics. A subsequent serialization
1269 // must see a clean state (no leaking into a stale sidecar).
1270 let minter = CredentialMinter::new_sealed(sample_issuer());
1271 let cred: Credential<String> =
1272 minter.mint("panic-time-secret".to_string(), sample_metadata());
1273
1274 // Run a closure that installs the guard then panics. We use
1275 // catch_unwind so the test framework doesn't abort.
1276 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1277 let _guard = DispatchCaptureGuard::install();
1278 // Confirm capture is active.
1279 DISPATCH_SIDECAR.with(|cell| {
1280 assert!(cell.borrow().is_some(), "sidecar installed");
1281 });
1282 panic!("simulated panic mid-serialization");
1283 }));
1284
1285 // After the panic, the thread-local must be clean.
1286 DISPATCH_SIDECAR.with(|cell| {
1287 assert!(
1288 cell.borrow().is_none(),
1289 "sidecar must be cleared on panic — got Some"
1290 );
1291 });
1292
1293 // And a subsequent default serialization still emits the sentinel.
1294 let v = serde_json::to_value(&cred).expect("serialize after panic");
1295 let obj = v.as_object().expect("object");
1296 assert!(obj.contains_key("$credential"));
1297 // No stray value capture from a leaked sidecar.
1298 assert!(!serde_json::to_string(&v)
1299 .unwrap()
1300 .contains("panic-time-secret"));
1301 }
1302
1303 #[test]
1304 fn audit_projection_yields_metadata_only() {
1305 // Acceptance criterion 12: the audit projection produces only the
1306 // metadata; the inner value never appears in audit output.
1307 let minter = CredentialMinter::new_sealed(sample_issuer());
1308 let cred: Credential<String> =
1309 minter.mint("audit-secret".to_string(), sample_metadata());
1310 let projection: &CredentialMetadata = cred.audit_projection();
1311 // The projection is metadata-equivalent...
1312 let audit_json = serde_json::to_string(projection).expect("audit serialize");
1313 // ...and does not contain the inner value.
1314 assert!(
1315 !audit_json.contains("audit-secret"),
1316 "audit projection must not include inner value, got: {audit_json}"
1317 );
1318 // It does include the metadata's `kind`.
1319 assert!(audit_json.contains("\"kind\""));
1320 }
1321
1322 #[test]
1323 fn metadata_serialization_round_trips() {
1324 // The ticket's third explicit test in the Acceptance gate: metadata
1325 // serialization round-trips.
1326 let original = sample_metadata();
1327 let json = serde_json::to_string(&original).expect("serialize");
1328 let parsed: CredentialMetadata = serde_json::from_str(&json).expect("deserialize");
1329 assert_eq!(parsed, original);
1330 }
1331
1332 #[test]
1333 fn metadata_round_trips_with_oauth_other_variant() {
1334 // Tier B Q-FLOW-2: the `Other { name }` escape valve round-trips.
1335 let mut m = sample_metadata();
1336 m.kind = CredentialKind::Other {
1337 name: CredentialKindName::new("trak.custom"),
1338 };
1339 let json = serde_json::to_string(&m).expect("serialize");
1340 let parsed: CredentialMetadata = serde_json::from_str(&json).expect("deserialize");
1341 assert_eq!(parsed, m);
1342 }
1343
1344 #[test]
1345 fn attachment_site_variants_round_trip() {
1346 // Each AttachmentSite variant serializes and deserializes cleanly.
1347 let variants = vec![
1348 AttachmentSite::Header {
1349 name: HeaderName::try_new("authorization").unwrap(),
1350 },
1351 AttachmentSite::Cookie {
1352 name: CookieName::try_new("plexus_session").unwrap(),
1353 },
1354 AttachmentSite::FirstFrame {
1355 setup_method: MethodPath::try_new("auth.connect").unwrap(),
1356 param: ParamName::new("token"),
1357 },
1358 AttachmentSite::InRpcParam {
1359 param: ParamName::new("session_token"),
1360 },
1361 ];
1362 for site in variants {
1363 let json = serde_json::to_string(&site).expect("serialize");
1364 let parsed: AttachmentSite = serde_json::from_str(&json).expect("deserialize");
1365 assert_eq!(parsed, site);
1366 }
1367 }
1368
1369 #[test]
1370 fn credential_kind_round_trips_all_variants() {
1371 // The closed enum has 8 variants; each serializes and deserializes.
1372 let variants = vec![
1373 CredentialKind::Bearer,
1374 CredentialKind::Cookie,
1375 CredentialKind::OauthAccess,
1376 CredentialKind::OauthRefresh,
1377 CredentialKind::OidcId,
1378 CredentialKind::AwsSts,
1379 CredentialKind::Macaroon,
1380 CredentialKind::Other {
1381 name: CredentialKindName::new("custom_scheme"),
1382 },
1383 ];
1384 for k in variants {
1385 let json = serde_json::to_string(&k).expect("serialize");
1386 let parsed: CredentialKind = serde_json::from_str(&json).expect("deserialize");
1387 assert_eq!(parsed, k);
1388 }
1389 }
1390
1391 #[test]
1392 fn newtypes_serialize_transparently() {
1393 // strong-typing skill: newtypes carry `#[serde(transparent)]` so they
1394 // serialize as bare strings, not as `{"0": "..."}`.
1395 let s = serde_json::to_string(&Scope::new("cone.send_message")).unwrap();
1396 assert_eq!(s, "\"cone.send_message\"");
1397 let h = serde_json::to_string(&HeaderName::try_new("authorization").unwrap()).unwrap();
1398 assert_eq!(h, "\"authorization\"");
1399 }
1400
1401 #[test]
1402 fn nested_struct_with_credential_field_serializes_sentinel() {
1403 // Demonstrates the headline behavior: a domain struct containing a
1404 // `Credential<T>` field serializes that field as a sentinel ref,
1405 // even though the rest of the struct serializes normally.
1406 #[derive(Serialize)]
1407 struct LoginEvent {
1408 user_id: String,
1409 session: Credential<String>,
1410 }
1411 let minter = CredentialMinter::new_sealed(sample_issuer());
1412 let event = LoginEvent {
1413 user_id: "alice".to_string(),
1414 session: minter.mint("jwt-bytes".to_string(), sample_metadata()),
1415 };
1416 let json = serde_json::to_value(&event).expect("serialize");
1417 let session_field = json.get("session").expect("session field");
1418 let sentinel = session_field
1419 .get("$credential")
1420 .and_then(|v| v.as_str())
1421 .expect("sentinel string");
1422 assert_eq!(sentinel, event.session.id().as_str());
1423 // The user_id is normal.
1424 assert_eq!(json.get("user_id").and_then(|v| v.as_str()), Some("alice"));
1425 // The inner JWT bytes do not appear.
1426 let s = serde_json::to_string(&event).unwrap();
1427 assert!(!s.contains("jwt-bytes"), "inner JWT must not appear: {s}");
1428 }
1429
1430 #[test]
1431 fn nested_struct_with_credential_emits_sidecar_under_guard() {
1432 // Mirrors the previous test but with the dispatch guard active —
1433 // the sidecar captures the inner value while the outer JSON still
1434 // contains only the sentinel.
1435 #[derive(Serialize)]
1436 struct LoginEvent {
1437 user_id: String,
1438 session: Credential<String>,
1439 }
1440 let minter = CredentialMinter::new_sealed(sample_issuer());
1441 let event = LoginEvent {
1442 user_id: "bob".to_string(),
1443 session: minter.mint("oauth-access".to_string(), sample_metadata()),
1444 };
1445 let (json, captured) =
1446 with_dispatch_capture(|| serde_json::to_value(&event).expect("serialize"));
1447 // Outer JSON: still sentinel.
1448 assert!(json.get("session").unwrap().get("$credential").is_some());
1449 // Sidecar: captured the value.
1450 let id = event.session.id();
1451 let entry = captured.get(id).expect("captured");
1452 assert_eq!(
1453 entry.value,
1454 serde_json::Value::String("oauth-access".into())
1455 );
1456 }
1457
1458 // -----------------------------------------------------------------
1459 // AUTHZ-CRED-CORE-1B: scoped-callback public entry point.
1460 // -----------------------------------------------------------------
1461
1462 #[test]
1463 fn run_with_credential_capture_empty_returns_no_captures() {
1464 // Acceptance criterion 2 (empty case): `f` produces no
1465 // `Credential<T>` values; the returned Vec is empty and the
1466 // closure's output is propagated.
1467 let (out, captured) = run_with_credential_capture(|| {
1468 // Serialize a plain payload that contains no credentials.
1469 let v = serde_json::to_value(serde_json::json!({"x": 1})).unwrap();
1470 v
1471 });
1472 assert_eq!(out, serde_json::json!({"x": 1}));
1473 assert!(
1474 captured.is_empty(),
1475 "no credentials emitted -> empty Vec; got {captured:?}"
1476 );
1477 }
1478
1479 #[test]
1480 fn run_with_credential_capture_populated_returns_real_captures() {
1481 // Acceptance criterion 2 (populated case): `f` calls
1482 // `Credential<T>::serialize` while the guard is installed; the
1483 // returned Vec contains the captured credentials with their ids,
1484 // values, and metadata. The outer serialization still produces
1485 // sentinels — the value lives only in the sidecar.
1486 let minter = CredentialMinter::new_sealed(sample_issuer());
1487 let cred: Credential<String> =
1488 minter.mint("populated-secret".to_string(), sample_metadata());
1489 let cred_id = cred.id().clone();
1490
1491 let (json_value, captured) =
1492 run_with_credential_capture(|| serde_json::to_value(&cred).expect("serialize"));
1493
1494 // Outer JSON: sentinel-only.
1495 let obj = json_value.as_object().expect("object");
1496 assert_eq!(obj.len(), 1);
1497 assert_eq!(
1498 obj.get("$credential").and_then(|v| v.as_str()),
1499 Some(cred_id.as_str())
1500 );
1501
1502 // Sidecar Vec: one entry, value+metadata+id present.
1503 assert_eq!(captured.len(), 1);
1504 let entry = &captured[0];
1505 assert_eq!(entry.id, cred_id);
1506 assert_eq!(
1507 entry.value,
1508 serde_json::Value::String("populated-secret".into())
1509 );
1510 assert_eq!(entry.metadata.kind, CredentialKind::Bearer);
1511 }
1512
1513 #[test]
1514 fn run_with_credential_capture_nested_sidecars_do_not_interleave() {
1515 // Ticket §Risks #1: nested invocations install fresh inner
1516 // sidecars; inner captures drain to the inner caller; the outer
1517 // continues with its own (still-pending) sidecar after the inner
1518 // returns. The two sidecars never interleave.
1519 let minter = CredentialMinter::new_sealed(sample_issuer());
1520 let outer_cred: Credential<String> =
1521 minter.mint("outer-secret".to_string(), sample_metadata());
1522 let inner_cred: Credential<String> =
1523 minter.mint("inner-secret".to_string(), sample_metadata());
1524 let outer_id = outer_cred.id().clone();
1525 let inner_id = inner_cred.id().clone();
1526
1527 let (outer_result, outer_captured) = run_with_credential_capture(|| {
1528 // Serialize outer credential under the outer guard.
1529 let _outer_json = serde_json::to_value(&outer_cred).expect("outer serialize");
1530
1531 // Now nest a fresh scope; the inner credential should be
1532 // captured ONLY by the inner guard.
1533 let (inner_json, inner_captured) =
1534 run_with_credential_capture(|| serde_json::to_value(&inner_cred).expect("inner"));
1535 assert_eq!(inner_captured.len(), 1, "inner scope captures inner only");
1536 assert_eq!(inner_captured[0].id, inner_id);
1537 assert_eq!(
1538 inner_captured[0].value,
1539 serde_json::Value::String("inner-secret".into())
1540 );
1541
1542 // After the inner scope returns, outer captures must still
1543 // include only the outer credential (the inner sidecar drained
1544 // to its caller; the outer sidecar — which is now active again
1545 // — still holds outer's capture).
1546 (_outer_json, inner_json)
1547 });
1548
1549 // Outer sidecar: outer credential only (the inner drained
1550 // separately).
1551 assert_eq!(
1552 outer_captured.len(),
1553 1,
1554 "outer scope contains outer only; got {outer_captured:?}"
1555 );
1556 assert_eq!(outer_captured[0].id, outer_id);
1557 assert_eq!(
1558 outer_captured[0].value,
1559 serde_json::Value::String("outer-secret".into())
1560 );
1561
1562 // The closure returned both serialized values.
1563 let (outer_json, inner_json) = outer_result;
1564 assert_eq!(
1565 outer_json.get("$credential").and_then(|v| v.as_str()),
1566 Some(outer_id.as_str())
1567 );
1568 assert_eq!(
1569 inner_json.get("$credential").and_then(|v| v.as_str()),
1570 Some(inner_id.as_str())
1571 );
1572 }
1573
1574 #[test]
1575 fn run_with_credential_capture_resets_on_panic() {
1576 // Ticket §"Required behavior": if `f` panics, the guard's `Drop`
1577 // releases the toggle on unwind; subsequent reentrant calls see
1578 // a clean state.
1579 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1580 run_with_credential_capture(|| {
1581 // Active sidecar.
1582 DISPATCH_SIDECAR.with(|cell| assert!(cell.borrow().is_some()));
1583 panic!("simulated panic inside run_with_credential_capture");
1584 });
1585 }));
1586
1587 // After the panic, the thread-local must be clean.
1588 DISPATCH_SIDECAR.with(|cell| {
1589 assert!(
1590 cell.borrow().is_none(),
1591 "sidecar must be cleared on panic"
1592 );
1593 });
1594
1595 // And a subsequent call works cleanly.
1596 let (out, captured) = run_with_credential_capture(|| 7_u32);
1597 assert_eq!(out, 7);
1598 assert!(captured.is_empty());
1599 }
1600
1601 #[test]
1602 fn run_with_credential_capture_isolated_per_thread() {
1603 // Ticket §Risks #3: the existing guard uses a thread-local;
1604 // multi-threaded dispatch must isolate per-task captures. Two
1605 // threads each running their own `run_with_credential_capture`
1606 // see only their own credentials.
1607 use std::sync::Arc;
1608 use std::thread;
1609
1610 let minter = Arc::new(CredentialMinter::new_sealed(sample_issuer()));
1611 let m1 = minter.clone();
1612 let m2 = minter.clone();
1613
1614 let h1 = thread::spawn(move || {
1615 let cred: Credential<String> =
1616 m1.mint("thread-1-secret".to_string(), sample_metadata());
1617 let id = cred.id().clone();
1618 let (_v, captured) =
1619 run_with_credential_capture(|| serde_json::to_value(&cred).unwrap());
1620 (id, captured)
1621 });
1622 let h2 = thread::spawn(move || {
1623 let cred: Credential<String> =
1624 m2.mint("thread-2-secret".to_string(), sample_metadata());
1625 let id = cred.id().clone();
1626 let (_v, captured) =
1627 run_with_credential_capture(|| serde_json::to_value(&cred).unwrap());
1628 (id, captured)
1629 });
1630
1631 let (id1, c1) = h1.join().unwrap();
1632 let (id2, c2) = h2.join().unwrap();
1633
1634 assert_eq!(c1.len(), 1, "thread 1 captures only its own credential");
1635 assert_eq!(c1[0].id, id1);
1636 assert_eq!(c1[0].value, serde_json::Value::String("thread-1-secret".into()));
1637
1638 assert_eq!(c2.len(), 1, "thread 2 captures only its own credential");
1639 assert_eq!(c2[0].id, id2);
1640 assert_eq!(c2[0].value, serde_json::Value::String("thread-2-secret".into()));
1641
1642 assert_ne!(id1, id2);
1643 }
1644
1645 #[test]
1646 fn multiple_credentials_get_distinct_ids() {
1647 // OAuth-style multi-credential return: each Credential<T> in the
1648 // event gets its own id and own sidecar entry under the guard.
1649 #[derive(Serialize)]
1650 struct TokenSet {
1651 access: Credential<String>,
1652 refresh: Credential<String>,
1653 }
1654 let minter = CredentialMinter::new_sealed(sample_issuer());
1655 let mut m_refresh = sample_metadata();
1656 m_refresh.kind = CredentialKind::OauthRefresh;
1657 let event = TokenSet {
1658 access: minter.mint("access-tok".to_string(), sample_metadata()),
1659 refresh: minter.mint("refresh-tok".to_string(), m_refresh),
1660 };
1661 assert_ne!(event.access.id(), event.refresh.id());
1662 let (_json, captured) =
1663 with_dispatch_capture(|| serde_json::to_value(&event).expect("serialize"));
1664 assert_eq!(captured.len(), 2);
1665 assert!(captured.contains_key(event.access.id()));
1666 assert!(captured.contains_key(event.refresh.id()));
1667 }
1668
1669 #[test]
1670 fn credential_field_marker_constructs_via_new_and_struct_literal() {
1671 // Acceptance criteria 2 + 3 (this ticket): the canonical
1672 // `CredentialFieldMarker` is constructible from outside crate code
1673 // via both struct-literal (because fields are `pub`) and `new`.
1674 let m1 = CredentialFieldMarker::new(
1675 None,
1676 "session",
1677 CredentialKind::Bearer,
1678 AttachmentSite::Header {
1679 name: HeaderName::try_new("authorization").unwrap(),
1680 },
1681 Some(CredentialScheme::new("Bearer ")),
1682 vec![Scope::new("cone.send_message")],
1683 Some(MethodPath::try_new("auth.refresh").unwrap()),
1684 Some(MethodPath::try_new("auth.logout").unwrap()),
1685 );
1686 assert_eq!(m1.field, "session");
1687 assert!(m1.variant.is_none());
1688 assert!(matches!(m1.kind, CredentialKind::Bearer));
1689 assert!(matches!(m1.attach_as, AttachmentSite::Header { .. }));
1690 assert_eq!(m1.scheme.as_ref().map(|s| s.as_str()), Some("Bearer "));
1691 assert_eq!(m1.scopes.len(), 1);
1692
1693 // Struct-literal construction also works (this is the form the
1694 // macro emits).
1695 let m2 = CredentialFieldMarker {
1696 variant: Some("Issued"),
1697 field: "session",
1698 kind: CredentialKind::Bearer,
1699 attach_as: AttachmentSite::Header {
1700 name: HeaderName::try_new("authorization").unwrap(),
1701 },
1702 scheme: None,
1703 scopes: vec![],
1704 refresh_via: None,
1705 revoke_via: None,
1706 };
1707 assert_eq!(m2.variant, Some("Issued"));
1708 assert_eq!(m2.field, "session");
1709 }
1710
1711 #[test]
1712 fn credential_field_marker_composes_metadata() {
1713 // The marker carries the static metadata; `to_metadata` composes
1714 // a full `CredentialMetadata` with the runtime-supplied pieces.
1715 let marker = CredentialFieldMarker::new(
1716 None,
1717 "session",
1718 CredentialKind::Bearer,
1719 AttachmentSite::Header {
1720 name: HeaderName::try_new("authorization").unwrap(),
1721 },
1722 Some(CredentialScheme::new("Bearer ")),
1723 vec![Scope::new("cone.send_message")],
1724 None,
1725 None,
1726 );
1727 let issuer = CredentialIssuer::new(
1728 Origin::new("ws://localhost:4444"),
1729 MethodPath::try_new("auth.login").unwrap(),
1730 );
1731 let metadata = marker.to_metadata(None, issuer.clone());
1732 assert_eq!(metadata.kind, CredentialKind::Bearer);
1733 assert_eq!(metadata.issuer, issuer);
1734 assert!(metadata.sensitive, "always true per CredentialMetadata::new");
1735 }
1736
1737 // -----------------------------------------------------------------------
1738 // CredentialsRegistry — autoref-specialized fallback tests
1739 // (AUTHZ-CRED-MACRO-3).
1740 // -----------------------------------------------------------------------
1741
1742 /// Types that do NOT implement [`HasCredentialMarkers`] fall through to
1743 /// the [`CredentialsRegistryFallback`] blanket impl, which returns an
1744 /// empty slice. This is the "plain return type" path at the
1745 /// `#[plexus_macros::method]` call site.
1746 #[test]
1747 fn credentials_registry_fallback_returns_empty_slice() {
1748 // Bring the fallback trait into scope so its `marker_slice`
1749 // is callable via autoref — this mirrors what the macro emits.
1750 use super::CredentialsRegistryFallback as _;
1751
1752 struct PlainType;
1753 let probe = CredentialsRegistryProbe::<PlainType>::new();
1754 // Autoref: `&probe` resolves to the fallback impl on
1755 // `&CredentialsRegistryProbe<T>`.
1756 let markers: &'static [CredentialFieldMarker] = (&probe).marker_slice();
1757 assert!(markers.is_empty(), "plain type yields empty marker slice");
1758 }
1759
1760 /// The probe is constructible for arbitrary types, including types that
1761 /// are not `Sized` and types that come from other crates. Smoke-checks
1762 /// `CredentialsRegistryProbe::new()` is `const`.
1763 #[test]
1764 fn credentials_registry_probe_constructible_for_arbitrary_types() {
1765 let _: CredentialsRegistryProbe<String> = CredentialsRegistryProbe::new();
1766 let _: CredentialsRegistryProbe<Vec<u8>> = CredentialsRegistryProbe::new();
1767 // `const` smoke-check.
1768 const _: CredentialsRegistryProbe<()> = CredentialsRegistryProbe::new();
1769 }
1770
1771 /// Types that implement [`HasCredentialMarkers`] hit the specialized
1772 /// inherent `marker_slice` on `CredentialsRegistryProbe<T>` — the
1773 /// "wired return type" path at the `#[plexus_macros::method]` call site.
1774 /// Demonstrates the autoref-specialization win.
1775 #[test]
1776 fn credentials_registry_specialized_path_returns_populated_slice() {
1777 use super::CredentialsRegistryFallback as _;
1778
1779 struct WiredType;
1780
1781 // Lazy static slice, mirroring what the derive macro will emit via
1782 // `OnceLock::get_or_init(|| credential_markers_for_<Type>())`.
1783 impl HasCredentialMarkers for WiredType {
1784 fn credential_markers() -> &'static [CredentialFieldMarker] {
1785 use std::sync::OnceLock;
1786 static M: OnceLock<Vec<CredentialFieldMarker>> = OnceLock::new();
1787 M.get_or_init(|| {
1788 vec![CredentialFieldMarker::new(
1789 None,
1790 "session",
1791 CredentialKind::Bearer,
1792 AttachmentSite::Header {
1793 name: HeaderName::try_new("authorization").unwrap(),
1794 },
1795 Some(CredentialScheme::new("Bearer ")),
1796 vec![],
1797 None,
1798 None,
1799 )]
1800 })
1801 .as_slice()
1802 }
1803 }
1804
1805 let probe = CredentialsRegistryProbe::<WiredType>::new();
1806
1807 // Direct call on the probe (no autoref): the inherent method gated
1808 // on `T: HasCredentialMarkers` resolves first.
1809 let m = probe.marker_slice();
1810 assert_eq!(m.len(), 1);
1811 assert_eq!(m[0].field, "session");
1812 assert!(matches!(m[0].kind, CredentialKind::Bearer));
1813
1814 // Same call with leading `&`: autoref-resolution still prefers the
1815 // inherent (gated) method over the `&CredentialsRegistryProbe<T>`
1816 // fallback impl. This is the actual call shape used by the macro.
1817 let m2 = (&probe).marker_slice();
1818 assert_eq!(
1819 m2.len(),
1820 1,
1821 "inherent specialized path wins over blanket fallback via autoref"
1822 );
1823 assert_eq!(m2[0].field, "session");
1824 }
1825
1826 /// Sanity-check the deprecated alias `CredentialsRegistry` still works
1827 /// for back-compat with any in-flight call sites that used the original
1828 /// name.
1829 #[test]
1830 #[allow(deprecated)]
1831 fn credentials_registry_deprecated_alias_compiles() {
1832 struct PlainType;
1833 let probe = CredentialsRegistryProbe::<PlainType>::new();
1834 // Bring the deprecated alias into scope. The supertrait relationship
1835 // (alias: Fallback) means autoref still finds the empty-slice path.
1836 use super::CredentialsRegistry as _;
1837 let markers = (&probe).marker_slice();
1838 assert!(markers.is_empty());
1839 }
1840}