ppoppo-token 0.28.0

JWT (RFC 9068, EdDSA) issuance + verification engine for the Ppoppo ecosystem. Single deep module with a small interface (issue, verify) hiding RFC 8725 mitigations M01-M45, JWKS handling, and substrate ports (epoch, session, replay).
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
//! Domain claim attack-surface checks — M39-M45 (ppoppo-specific).
//!
//! Mirror of `check_claims` but for ppoppo's domain extensions to the
//! RFC-registered claim set. The split is by SOURCE OF AUTHORITY:
//!
//! - `check_claims` enforces RFC 8725 / 9068 / 7519 (industry-standard
//!   registered claims — exp/iat/nbf/aud/iss/jti/sub/client_id/cat).
//! - `check_domain` (this module) enforces ppoppo's own contract — claim
//!   shapes the standards never mention (`entity_type`, `caps`, `act`,
//!   `admin`, `scopes`, `cid`, `sv`, `active_ppnum`) plus a strict
//!   allowlist that locks out PII (M45).
//!
//! ── Deep module shape ────────────────────────────────────────────────────
//!
//! Single `pub(crate) fn run` entry point — every M-row check fires from
//! this one call site. Internal helpers stay private. Callers (`engine/
//! mod.rs::verify`) see one function; the implementation hides the seven
//! row-by-row enforcements. Future M-rows append helper calls inside
//! `run`; the outward shape never changes.
//!
//! ── Order of operations ─────────────────────────────────────────────────
//!
//! Cheaper structural rejects fire before semantic rules so an attacker
//! probing the surface sees the same audit signal regardless of which
//! later rule would also fire. Specific variant first, generic
//! `UnknownClaim` (M45) last — the allowlist is the catch-net.

use crate::access_token::{Act, AuthError, Claims, EntityType, VerifyConfig};
use crate::engine::raw::parse_payload_json;

/// Run every domain check (M39 in commit 4.1; appended to in 4.2-4.7).
///
/// Takes the partially-built `Claims` (registered fields proven by
/// `check_claims`) plus the raw token (so this checker can re-parse the
/// payload for claims `Claims` doesn't surface). Returns the same
/// `Claims` with surfaced domain fields populated — the by-value shape
/// keeps mutation contained inside the engine and gives `verify` a
/// single composed result.
///
/// Order: cheaper structural rejects first (M39 sub format), then
/// payload-driven checks (M40+). The payload re-parse cost is one
/// `serde_json::from_str` per verify (~µs); the alternative (returning
/// the parsed JSON from `check_claims`) couples two checkers' return
/// types and pushes domain knowledge into a registered-claims module.
pub(crate) fn run(
    token: &str,
    mut claims: Claims,
    _cfg: &VerifyConfig,
) -> Result<Claims, AuthError> {
    // M39: `sub` MUST be a 26-character Crockford-base32 ULID. PAS-issued
    // tokens carry `ppnum_id` (Human) or an AI-agent ULID; any other
    // shape is either issuer drift or forgery. `Ulid::from_string`
    // rejects both wrong length and out-of-alphabet characters in one
    // call — using the canonical parser instead of a hand-rolled regex
    // keeps the validation in lockstep with the issuer's `Ulid::new`.
    if ulid::Ulid::from_string(&claims.sub).is_err() {
        return Err(AuthError::SubFormatInvalid);
    }

    let payload = parse_payload_json(token)?;

    // M40: `entity_type` must name a credential-eligible entity when
    // present. Absence is admitted (1st-party OTP/passkey flows carry no
    // such claim). Two rejections, deliberately distinct in cause:
    //
    //  1. Outside the vocabulary at all (`"delegated"`, `"bot"`, a
    //     non-string) — PAS issuance never emits free-form strings, so
    //     this is a forgery signal.
    //  2. In the vocabulary but not credential-eligible (`enterprise`,
    //     `mask`) — a real entity kind with no credential-issuing flow.
    //     PAS refuses to mint for such a principal, so a token bearing
    //     one did not come from PAS either.
    //
    // Neither set is restated here. `EntityType::parse` is the
    // vocabulary and `can_hold_credential()` is the admitted subset —
    // both read from `ppoppo_identity::TABLE`, the same declaration the
    // mint path consults. That single source is what lets the K8
    // reachability guard assert `producible == admitted` as an equality
    // rather than an inclusion (RFC_202607252223 §4.2).
    if let Some(value) = payload.get("entity_type") {
        let s = value.as_str().ok_or(AuthError::EntityTypeInvalid)?;
        let entity = EntityType::parse(s).ok_or(AuthError::EntityTypeInvalid)?;
        if !entity.can_hold_credential() {
            return Err(AuthError::EntityTypeInvalid);
        }
        claims.entity_type = Some(entity);
    }

    // M41: `caps` MUST be a JSON array of strings when present. Absence
    // and empty array both collapse to "no capabilities" on the
    // surfaced `Claims.caps` (default-deny). Engine validates the wire
    // shape only — semantic interpretation of capability strings is
    // per-surface. A string-typed `caps: "admin"` is the canonical
    // forgery vector and is rejected with `CapsShapeInvalid`.
    if let Some(value) = payload.get("caps") {
        claims.caps = parse_string_array(value, AuthError::CapsShapeInvalid)?;
    }

    // M44: admin band gate. When `admin: true`, `active_ppnum` MUST be
    // present and its first 3 digits MUST fall in the admin band
    // (`[100, 109]` — Phase 4 hardcodes; Phase 5+ may load from cfg).
    // Defense in depth on top of `is_admin` DB lookup
    // (STS_AUTH_PPOPPO §3.2 — DB is the source of truth):
    // narrows a stolen-signing-key forgery surface from "any ppnum" to
    // "an admin-banded ppnum". A non-bool `admin` claim is treated as
    // forgery — PAS issuance never emits other shapes.
    let admin = match payload.get("admin") {
        None => false,
        Some(v) => v.as_bool().ok_or(AuthError::AdminBandRejected)?,
    };
    let active_ppnum_str = payload.get("active_ppnum").and_then(|v| v.as_str());
    if admin {
        let ppnum = active_ppnum_str.ok_or(AuthError::AdminBandRejected)?;
        if !is_in_admin_band(ppnum) {
            return Err(AuthError::AdminBandRejected);
        }
    }
    claims.admin = admin;
    claims.active_ppnum = active_ppnum_str.map(String::from);

    // M43: `act` (RFC 8693 §4.1) — the acting party, when present.
    // Absence is "nobody is acting for this subject"; nothing can
    // disagree with that, because the chain depth IS the nesting depth
    // rather than a second claim carried alongside it. A token cannot
    // misreport a depth it does not state.
    //
    // Unlike `dlg_depth`, the value is SURFACED: `Claims.act` is half of
    // the delegation predicate (`entity_type == Human && act.is_some()`),
    // which is a consumer decision, not one the engine can resolve.
    //
    // **This parse is also doing M45's job.** The allowlist below scans
    // top-level keys only — fine while every claim was a scalar, but
    // `act` is the first object-valued one, so its interior is a region
    // the allowlist structurally cannot see. `Act` is
    // `deny_unknown_fields` and self-referential, so the strictness
    // recurses: `act: {"sub": …, "email": …}` is rejected here or it is
    // rejected nowhere.
    if let Some(value) = payload.get("act") {
        let act: Act =
            serde_json::from_value(value.clone()).map_err(|_| AuthError::ActShapeInvalid)?;
        if act.depth() > MAX_ACT_DEPTH {
            return Err(AuthError::ActTooDeep);
        }
        claims.act = Some(act);
    }

    // M42: `scopes` MUST be a JSON array of strings AND have length ≤ 256.
    // Same default-deny collapse as `caps`. The 256 cap bounds the
    // per-request scope-check cost — a misconfigured issuer (or a forger
    // who got hold of a signing key) cannot mint a token whose
    // authorization vector is itself a DoS. Length check fires AFTER
    // shape so audit logs distinguish "wire malformed" (M42 shape) from
    // "issuer overshot" (M42 length).
    if let Some(value) = payload.get("scopes") {
        let scopes = parse_string_array(value, AuthError::ScopesShapeInvalid)?;
        if scopes.len() > MAX_SCOPES {
            return Err(AuthError::ScopesTooLong);
        }
        claims.scopes = scopes;
    }

    // Surface `cid` (Phase 2 Decision 1 plan): legitimately needed
    // post-verify for passkey forensics. Wire-shape check is "string or
    // absent".
    claims.cid = payload
        .get("cid")
        .and_then(|v| v.as_str())
        .map(String::from);
    // `sid` (M36) — surfaced when present so `engine/check_session.rs`
    // (Phase 5 commit 5.2) can hand it to `cfg.session_revocation`.
    // Absent on PAS-internal machine tokens and pre-Phase-5 tokens; the
    // session-revocation gate short-circuits when `None`.
    claims.sid = payload
        .get("sid")
        .and_then(|v| v.as_str())
        .map(String::from);

    // M45: PII allowlist. PAS issuance only emits claims in the
    // canonical set; anything else is forgery / smuggling / stale-PII.
    // Order: fires LAST so specific-variant rejects (M39-M44) get the
    // precise audit signal first; only after every typed check passes
    // does the catch-net allowlist run. Unknown claim names surface in
    // the variant payload so audit logs see WHICH claim tripped the
    // rejection (`email` looks very different from `x_attacker_marker`
    // even though both end up here).
    if let Some(obj) = payload.as_object() {
        for key in obj.keys() {
            if !ALLOWED_CLAIMS.contains(&key.as_str()) {
                return Err(AuthError::UnknownClaim(key.clone()));
            }
        }
    }

    Ok(claims)
}

/// Maximum number of `scopes` entries (M42). The 256 bound comes from
/// RFC §6.5; raising it requires a coordinated update with PAS issuance
/// (which already hard-caps at the same value via API limits) so the
/// constant is named here rather than inlined.
const MAX_SCOPES: usize = 256;

/// M45 PII allowlist — every claim name PAS issuance is permitted to
/// emit. Anything outside this set is a forgery / smuggling /
/// stale-PII signal and is rejected with `AuthError::UnknownClaim`.
///
/// Adding a new claim is a 4-step change (in this order):
/// 1. Append the wire name here.
/// 2. Add the field to `IssueRequest` + `with_*` builder.
/// 3. Add the field to `IssuePayload` with the right
///    `skip_serializing_if`.
/// 4. Surface (or hide) on `Claims` per the Phase 2 Decision 1 rule
///    ("only surface what callers legitimately need post-verify").
///
/// Skipping any step 1-3 leaves `issue` unable to emit the claim;
/// skipping step 1 leaves `verify` rejecting tokens that contain it.
const ALLOWED_CLAIMS: &[&str] = &[
    // Registered (RFC 7519 + 9068)
    "iss",
    "sub",
    "aud",
    "exp",
    "iat",
    "nbf",
    "jti",
    "client_id",
    "cat",
    // Domain (Phase 4 — M40+)
    "entity_type",
    "admin",
    "caps",
    "act",
    "cid",
    "sv",
    "sid",
    "active_ppnum",
    "scopes",
];

/// Maximum delegation chain depth (M43) — the number of nested `act`
/// links. The inclusive bound of 4 matches RFC §6.5: past four hops the
/// audit trail explodes faster than the legitimate use cases can justify.
///
/// Measured from the structure rather than read from a claim, so this is
/// the first shape of the bound that a forger cannot simply understate.
const MAX_ACT_DEPTH: usize = 4;

/// Admin allocation band — first 3 digits of an admin-eligible
/// `active_ppnum` fall in `[ADMIN_BAND_START, ADMIN_BAND_END]`. Phase 4
/// hardcodes `[100, 109]` (matching RFC §6.5 / STS_AUTH_PPOPPO
/// §11.x); Phase 5+ migration to `VerifyConfig::admin_bands` is tracked
/// in the Phase 5 NEXT_PROMPT. **STANDARDS line 73** ("코드는 prefix 값을
/// 모름") favours the cfg-driven shape long-term — the constant lives
/// here only because the band is policy-stable today and the cfg
/// surface adds a public field that Phase 5 can land surgically.
const ADMIN_BAND_START: u16 = 100;
const ADMIN_BAND_END: u16 = 109;

/// True when `active_ppnum`'s first 3 digits parse into the admin band
/// `[ADMIN_BAND_START, ADMIN_BAND_END]`. Tokens carry the digit-only
/// storage form (`^[0-9]{11,}$` — STANDARDS line 73); display form
/// (`123-1234-5678`) on the wire is itself a forgery / misconfiguration
/// signal and is rejected by the all-digits guard below.
fn is_in_admin_band(active_ppnum: &str) -> bool {
    if active_ppnum.len() < 3 {
        return false;
    }
    if !active_ppnum.chars().all(|c| c.is_ascii_digit()) {
        return false;
    }
    match active_ppnum[..3].parse::<u16>() {
        Ok(band) => (ADMIN_BAND_START..=ADMIN_BAND_END).contains(&band),
        Err(_) => false,
    }
}

/// Parse a JSON value as an array of strings, mapping any wire-shape
/// failure to the supplied variant. Used by both `caps` (M41) and
/// `scopes` (M42) — they share the array-of-strings contract but get
/// distinct audit variants because their threat models differ
/// (capability confusion vs scope confusion).
fn parse_string_array(
    value: &serde_json::Value,
    on_invalid: AuthError,
) -> Result<Vec<String>, AuthError> {
    let array = value.as_array().ok_or(on_invalid.clone())?;
    let mut out = Vec::with_capacity(array.len());
    for item in array {
        let s = item.as_str().ok_or(on_invalid.clone())?;
        out.push(s.to_string());
    }
    Ok(out)
}

#[cfg(test)]
mod tests {
    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
    use super::*;

    fn cfg() -> VerifyConfig {
        VerifyConfig::access_token(
            "https://accounts.ppoppo.com",
            "ppoppo",
            crate::access_token::EpochEnforcement::Unenforced {
                reason: "test: epoch axis not under test",
            },
        )
    }

    fn claims_with_sub(sub: &str) -> Claims {
        Claims {
            iss: "https://accounts.ppoppo.com".to_string(),
            sub: sub.to_string(),
            exp: 9_999_999_999,
            iat: 1_700_000_000,
            nbf: None,
            jti: "01HABC00000000000000000000".to_string(),
            client_id: "ppoppo-internal".to_string(),
            entity_type: None,
            caps: Vec::new(),
            scopes: Vec::new(),
            admin: false,
            active_ppnum: None,
            act: None,
            cid: None,
            sid: None,
        }
    }

    /// Forge a JWS Compact payload with the supplied JSON and a
    /// throwaway header + sig. The M39 ULID check operates on
    /// `Claims.sub` directly, so the wire bytes don't matter for the
    /// sub-only tests below — but M40+ tests need a real payload to
    /// re-parse. Sharing the helper keeps the unit tests independent
    /// of the integration-level signed-token forger.
    fn forge_payload(payload: serde_json::Value) -> String {
        use base64::Engine;
        let header = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(
            serde_json::to_vec(&serde_json::json!({"alg":"EdDSA","typ":"at+jwt","kid":"k"}))
                .unwrap(),
        );
        let body = base64::engine::general_purpose::URL_SAFE_NO_PAD
            .encode(serde_json::to_vec(&payload).unwrap());
        format!("{header}.{body}.<sig>")
    }

    fn payload_with_sub(sub: &str) -> serde_json::Value {
        serde_json::json!({
            "iss": "https://accounts.ppoppo.com",
            "sub": sub,
            "aud": "ppoppo",
            "exp": 9_999_999_999i64,
            "iat": 1_700_000_000i64,
            "jti": "01HABC00000000000000000000",
            "client_id": "ppoppo-internal",
            "cat": "access",
        })
    }

    #[test]
    fn accepts_valid_ulid_sub() {
        // 01HSAB... uses only Crockford-base32 chars (S A B all valid).
        // The earlier scaffold sometimes typed `01HSUB...` because "SUB"
        // is the spelled-out concept — but `U` is *excluded* from
        // Crockford to avoid look-alike confusion with `V`. Using a real
        // ULID stops that footgun.
        let claims = claims_with_sub("01HSAB00000000000000000000");
        let token = forge_payload(payload_with_sub("01HSAB00000000000000000000"));
        assert!(run(&token, claims, &cfg()).is_ok());
    }

    #[test]
    fn rejects_too_short_sub() {
        let claims = claims_with_sub("00000000000"); // 11 digits — old ppnum
        let token = forge_payload(payload_with_sub("00000000000"));
        assert_eq!(
            run(&token, claims, &cfg()),
            Err(AuthError::SubFormatInvalid),
        );
    }

    #[test]
    fn rejects_non_crockford_alphabet() {
        // 'I' is excluded from Crockford base32 (collides with '1').
        let claims = claims_with_sub("I1HSUB00000000000000000000");
        let token = forge_payload(payload_with_sub("I1HSUB00000000000000000000"));
        assert_eq!(
            run(&token, claims, &cfg()),
            Err(AuthError::SubFormatInvalid),
        );
    }

    #[test]
    fn account_type_populated_when_valid() {
        let claims = claims_with_sub("01HSAB00000000000000000000");
        let mut payload = payload_with_sub("01HSAB00000000000000000000");
        payload["entity_type"] = serde_json::json!("human");
        let token = forge_payload(payload);
        let claims = run(&token, claims, &cfg()).expect("M40 valid");
        assert_eq!(claims.entity_type, Some(EntityType::Human));
    }

    #[test]
    fn account_type_programmable_admitted() {
        let claims = claims_with_sub("01HSAB00000000000000000000");
        let mut payload = payload_with_sub("01HSAB00000000000000000000");
        payload["entity_type"] = serde_json::json!("programmable");
        let token = forge_payload(payload);
        let claims = run(&token, claims, &cfg()).expect("M40 programmable admitted");
        assert_eq!(claims.entity_type, Some(EntityType::Programmable));
    }

    #[test]
    fn entity_type_outside_the_vocabulary_rejected() {
        for bad in ["robot", "delegated", "user", "bot"] {
            let claims = claims_with_sub("01HSAB00000000000000000000");
            let mut payload = payload_with_sub("01HSAB00000000000000000000");
            payload["entity_type"] = serde_json::json!(bad);
            let token = forge_payload(payload);
            assert_eq!(
                run(&token, claims, &cfg()),
                Err(AuthError::EntityTypeInvalid),
                "{bad:?} is not an entity type; `delegated` in particular is a \
                 session mode carried by the actor claim, never a value here",
            );
        }
    }

    /// **The M40 admitted set is the credential-eligible subset, not the
    /// whole vocabulary.** `enterprise` and `mask` are real entity kinds —
    /// they parse — but no mint path can produce a token for them, so a
    /// token bearing one did not come from PAS.
    ///
    /// This is the verify half of the K8 reachability equality
    /// (`producible == admitted`, RFC_202607252223 §4.2): both halves read
    /// `can_hold_credential` from the same table row, so opening the
    /// enterprise lane moves them together or not at all.
    #[test]
    fn entity_type_without_a_credential_flow_rejected() {
        for ineligible in EntityType::ALL
            .into_iter()
            .filter(|e| !e.can_hold_credential())
        {
            let claims = claims_with_sub("01HSAB00000000000000000000");
            let mut payload = payload_with_sub("01HSAB00000000000000000000");
            payload["entity_type"] = serde_json::json!(ineligible.as_str());
            let token = forge_payload(payload);
            assert_eq!(
                run(&token, claims, &cfg()),
                Err(AuthError::EntityTypeInvalid),
                "{ineligible} parses but cannot hold a credential — admitting it \
                 would let PAS verify a token it refuses to mint",
            );
        }
    }

    /// Build an `act` chain `hops` links deep.
    fn act_chain(hops: usize) -> serde_json::Value {
        let mut act = serde_json::json!({"sub": "01HSAB00000000000000000000"});
        for _ in 1..hops {
            act = serde_json::json!({"sub": "01HSAB00000000000000000000", "act": act});
        }
        act
    }

    fn run_with_act(act: serde_json::Value) -> Result<Claims, AuthError> {
        let claims = claims_with_sub("01HSAB00000000000000000000");
        let mut payload = payload_with_sub("01HSAB00000000000000000000");
        payload["act"] = act;
        run(&forge_payload(payload), claims, &cfg())
    }

    /// M43 — the bound is the nesting depth, and it is inclusive.
    #[test]
    fn act_chain_within_the_bound_is_admitted() {
        for hops in 1..=MAX_ACT_DEPTH {
            let claims = run_with_act(act_chain(hops))
                .unwrap_or_else(|e| panic!("{hops}-hop chain must admit, got {e:?}"));
            assert_eq!(
                claims.act.as_ref().map(Act::depth),
                Some(hops),
                "the surfaced chain must be the one on the wire"
            );
        }
    }

    #[test]
    fn act_chain_past_the_bound_is_rejected() {
        assert_eq!(
            run_with_act(act_chain(MAX_ACT_DEPTH + 1)),
            Err(AuthError::ActTooDeep),
        );
    }

    /// The two M43 causes stay distinguishable in audit logs (M42
    /// precedent): malformed wire is not the same event as an issuer
    /// overshooting the bound.
    #[test]
    fn act_that_is_not_an_actor_object_is_rejected() {
        for malformed in [
            serde_json::json!("01HSAB00000000000000000000"), // the retired flat shape
            serde_json::json!(["01HSAB00000000000000000000"]),
            serde_json::json!({}),                   // no `sub`
            serde_json::json!({"sub": 42}),          // `sub` not a string
            serde_json::json!({"subject": "01HSA"}), // near-miss key
        ] {
            assert_eq!(
                run_with_act(malformed.clone()),
                Err(AuthError::ActShapeInvalid),
                "{malformed} is not an RFC 8693 actor object",
            );
        }
    }

    /// **The M45 blind spot.** The allowlist scans top-level keys, so a
    /// claim smuggled *inside* `act` is invisible to it — the strict
    /// interior parse is the only thing standing there, at every depth.
    #[test]
    fn pii_smuggled_inside_act_is_rejected() {
        for smuggled in [
            serde_json::json!({"sub": "01HSAB00000000000000000000", "email": "a@b.c"}),
            serde_json::json!({
                "sub": "01HSAB00000000000000000000",
                "act": {"sub": "01HSAB00000000000000000000", "email": "a@b.c"},
            }),
        ] {
            assert_eq!(
                run_with_act(smuggled.clone()),
                Err(AuthError::ActShapeInvalid),
                "{smuggled} would otherwise be seen by nothing — M45 cannot \
                 reach inside an object-valued claim",
            );
        }
    }

    /// A chain deep enough to threaten the stack must die in the payload
    /// parse, before anything walks it. `serde_json` caps nesting at 128
    /// while building the `Value`; asserted rather than assumed, because
    /// the depth walk downstream is only safe if this holds.
    #[test]
    fn absurdly_deep_act_dies_in_the_payload_parse() {
        let claims = claims_with_sub("01HSAB00000000000000000000");
        let mut payload = payload_with_sub("01HSAB00000000000000000000");
        payload["act"] = act_chain(200);
        let result = run(&forge_payload(payload), claims, &cfg());
        assert!(
            !matches!(result, Ok(_) | Err(AuthError::ActTooDeep)),
            "a 200-deep chain must be refused while parsing the payload, not \
             by walking it: {result:?}"
        );
    }

    /// The complement, so the gate cannot quietly become deny-all.
    #[test]
    fn every_credential_eligible_entity_type_admitted() {
        for eligible in EntityType::ALL
            .into_iter()
            .filter(|e| e.can_hold_credential())
        {
            let claims = claims_with_sub("01HSAB00000000000000000000");
            let mut payload = payload_with_sub("01HSAB00000000000000000000");
            payload["entity_type"] = serde_json::json!(eligible.as_str());
            let token = forge_payload(payload);
            let claims = run(&token, claims, &cfg())
                .unwrap_or_else(|e| panic!("{eligible} must be admitted, got {e:?}"));
            assert_eq!(claims.entity_type, Some(eligible));
        }
    }
}