Skip to main content

bynk_check/
actors.rs

1//! v0.45 actor contracts (the actors-foundations slice).
2//!
3//! An `actor` declaration is a nominal *boundary contract* (ADR Q1): a closed,
4//! compiler-known authentication `Scheme` plus an optional sealed identity. A
5//! handler consumes an actor on its `by` clause; the boundary verifies the
6//! scheme and mints the identity before the body runs (two-phase, fail-closed —
7//! ADR Q5/Q2).
8//!
9//! This module holds the compiler-known parts: the closed scheme set, the
10//! prelude actors, the per-protocol default actors, and the admissible-scheme
11//! sets. Foundations admits only the two zero-crypto schemes (`None`,
12//! `Internal`); `Bearer`/`Signature` are reserved-and-rejected.
13
14use std::collections::HashMap;
15
16use bynk_syntax::ast::{
17    BinOp, ByClause, Expr, ExprKind, Handler, HandlerKind, ServiceProtocol, TypeRef, UnaryOp,
18};
19use bynk_syntax::span::Span;
20
21/// P6.49 (design/tracks/the-ir.md §6b), following P6.27's `ExprId` precedent:
22/// every reader in this module — [`bearer_seam_for`], [`oidc_seam_for`],
23/// [`signature_seam_for`], [`sum_members_for`], [`caller_binder_for`] — is
24/// already parameterised by `&HashMap<String, ActorDecl>`. `bynk-emit` clones
25/// and forwards this table (`project.rs`'s `EmitProjectCtx::actors`,
26/// `emitter/workers.rs`/`workers_entry.rs`'s own `actors` parameters) without
27/// ever reading an `ActorDecl` field itself — P6.34 investigated and declined
28/// resolving it earlier, since `bynk-emit`'s own `lower_actor_seam_ir` needs
29/// the raw declarations to do its own resolution; this re-export changes the
30/// import path, not that data flow.
31pub use bynk_syntax::ast::ActorDecl;
32
33/// The authentication scheme — a closed, compiler-known set (ADR Q1). Sealed
34/// now, openable later by widening this enum.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum Scheme {
37    /// Anonymous — no verification; identity is `()`. (`Visitor`.)
38    None,
39    /// In-system / platform trust — the channel itself is the assertion
40    /// (service-binding / platform dispatch). Admitted in Foundations.
41    Internal,
42    /// Bearer token — compiler-generated JWT/HS256 verification (ADR 0085).
43    Bearer,
44    /// Request signature — HMAC-SHA256 over the body (ADR 0089).
45    Signature,
46    /// OIDC/JWKS — compiler-generated verification of an asymmetrically-signed
47    /// (RS256/ES256) JWT against a provider's published JWKS, checking
48    /// `iss`/`aud`/`exp`/`nbf` and minting the identity from `sub` (ADR 0175).
49    /// The first scheme opened via the "user-configured verifier" route: the
50    /// trust root is the provider's public key set (a URL), so the declaration
51    /// carries **no inline secret** — only public trust parameters.
52    Oidc,
53}
54
55impl Scheme {
56    /// Classify a scheme name written in `auth = <Scheme>`. `None` means the
57    /// name is not one of the compiler-known schemes.
58    pub fn from_name(s: &str) -> Option<Scheme> {
59        Some(match s {
60            "None" => Scheme::None,
61            "Internal" => Scheme::Internal,
62            "Bearer" => Scheme::Bearer,
63            "Signature" => Scheme::Signature,
64            "Oidc" => Scheme::Oidc,
65            _ => return None,
66        })
67    }
68
69    /// The schemes the compiler can emit verification for. v0.45 admitted the
70    /// two zero-crypto schemes (`None`/`Internal`); v0.47 added `Bearer`
71    /// (JWT/HS256); v0.51 added `Signature` (HMAC over the body); v0.151 adds
72    /// `Oidc` (JWKS/RS256+ES256). All five schemes are now admitted.
73    pub fn admitted(self) -> bool {
74        matches!(
75            self,
76            Scheme::None | Scheme::Internal | Scheme::Bearer | Scheme::Signature | Scheme::Oidc
77        )
78    }
79
80    pub fn as_str(self) -> &'static str {
81        match self {
82            Scheme::None => "None",
83            Scheme::Internal => "Internal",
84            Scheme::Bearer => "Bearer",
85            Scheme::Signature => "Signature",
86            Scheme::Oidc => "Oidc",
87        }
88    }
89}
90
91/// The identity a verified actor yields (ADR Q2). In Foundations this is `()`
92/// for trivial actors, the built-in sealed `CallerId` for the cross-context
93/// `Internal` channel (Q7, folded in), or a context-owned declared type.
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub enum Identity {
96    /// `()` — `None` actors and platform-tag `Internal` actors.
97    Unit,
98    /// The built-in sealed calling-context identity (Q7). Minted at the
99    /// service-binding seam; read-only and never re-checked.
100    CallerId,
101    /// A context-owned declared type named in `identity = <T>`.
102    Declared(String),
103}
104
105/// The built-in sealed identity type for the cross-context calling principal.
106pub const CALLER_ID: &str = "CallerId";
107
108/// A resolved actor contract: its scheme and the identity it yields.
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct Contract {
111    pub scheme: Scheme,
112    pub identity: Identity,
113}
114
115/// The prelude actors — compiler-known boundary contracts available without a
116/// declaration. They back the per-protocol defaults and let public HTTP routes
117/// write `by v: Visitor` without ceremony.
118pub fn prelude_actor(name: &str) -> Option<Contract> {
119    Some(match name {
120        // Anonymous public surface — the only safe HTTP actor in Foundations.
121        "Visitor" => Contract {
122            scheme: Scheme::None,
123            identity: Identity::Unit,
124        },
125        // Platform schedulers / producers — Internal, carrying no useful
126        // identity payload (a bare tag).
127        "Scheduler" | "Producer" => Contract {
128            scheme: Scheme::Internal,
129            identity: Identity::Unit,
130        },
131        // The cross-context calling principal — Internal, yielding the sealed
132        // `CallerId` (Q7).
133        "Caller" => Contract {
134            scheme: Scheme::Internal,
135            identity: Identity::CallerId,
136        },
137        _ => return None,
138    })
139}
140
141/// The default actor a handler inherits when it omits `by`, by protocol (ADR
142/// Q5). HTTP has no safe default — `by` is required there.
143pub fn default_actor(protocol: &ServiceProtocol) -> Option<&'static str> {
144    match protocol {
145        ServiceProtocol::Call => Some("Caller"),
146        ServiceProtocol::Cron => Some("Scheduler"),
147        ServiceProtocol::Queue { .. } => Some("Producer"),
148        // Events track, slice 0 (spine #936): delivery is runtime-triggered,
149        // not an external caller — like `Queue`'s "Producer", the default
150        // actor names the event's originating publisher.
151        ServiceProtocol::Events { .. } => Some("Publisher"),
152        // v0.103: like HTTP, a WebSocket upgrade has no safe default actor —
153        // `by` is mandatory on `on open` (edge auth before accept, D-A).
154        ServiceProtocol::Http | ServiceProtocol::WebSocket { .. } => None,
155    }
156}
157
158/// v0.47: the data the emitter needs to lower a Bearer verification seam for a
159/// handler — the `by` binder (v0.50: `None` for the binder-less verify-and-
160/// discard form), the signing-secret env name, and the identity type to
161/// construct from the JWT `sub` claim. Resolved only for a handler whose `by`
162/// clause names a local Bearer actor; the checker guarantees the secret is
163/// present and the identity is a string-constructible local type.
164#[derive(Debug, Clone)]
165pub struct BearerSeam {
166    /// The identity binder, or `None` for `by <BearerActor>` (verify the token,
167    /// don't capture the identity). When `None` the seam still verifies fail-
168    /// closed but mints no identity and threads nothing into `deps`.
169    pub binder: Option<String>,
170    pub secret: String,
171    pub identity_type: String,
172    /// v0.53: the authorisation invariant when the `by` actor is a refinement
173    /// (`actor Admin = User where <pred>`). The seam verifies the scheme (401),
174    /// then checks this predicate against the verified claims (403 fail-closed),
175    /// then mints the (base) identity. `None` for a plain Bearer actor.
176    pub authorization: Option<ClaimPredicate>,
177}
178
179/// Resolve a handler's Bearer seam, if its `by` clause names a local Bearer
180/// actor — or a **refinement** of one (v0.53), following the refinement to its
181/// base for the scheme/secret/identity and carrying the authorisation
182/// predicate. Returns `None` for non-Bearer handlers (prelude actors are never
183/// Bearer) — those emit unchanged.
184/// #706: whether a `by` clause names a **Bearer**-secured actor (following a
185/// refinement to its base). The routes for which `by Nobody` can drive the auth
186/// seam to a `401` — an unsecured (`Visitor`/`None`) or `Signature`/`Oidc` route
187/// has no Bearer seam the test driver knows how to leave unauthenticated, so
188/// `by Nobody` there is rejected (`bynk.test.nobody_needs_secured_route`). The
189/// scheme check mirrors [`bearer_seam_for`].
190pub fn by_clause_is_bearer(by: &ByClause, actors: &HashMap<String, ActorDecl>) -> bool {
191    let Some(named) = actors.get(&by.primary().name) else {
192        return false;
193    };
194    let base = match &named.refinement {
195        Some(r) => match actors.get(&r.base.name) {
196            Some(b) => b,
197            None => return false,
198        },
199        None => named,
200    };
201    base.auth
202        .as_ref()
203        .and_then(|a| Scheme::from_name(a.name.as_str()))
204        == Some(Scheme::Bearer)
205}
206
207pub fn bearer_seam_for(
208    handler: &Handler,
209    actors: &HashMap<String, ActorDecl>,
210) -> Option<BearerSeam> {
211    let by = handler.by_clause.as_ref()?;
212    let named = actors.get(&by.primary().name)?;
213    // Follow a refinement to its base; carry the authorisation predicate. The
214    // checker guarantees a refinement's base is Bearer and its predicate parses.
215    let (base, authorization) = match &named.refinement {
216        Some(r) => (
217            actors.get(&r.base.name)?,
218            parse_claim_predicate(&r.predicate).ok(),
219        ),
220        None => (named, None),
221    };
222    if Scheme::from_name(base.auth.as_ref()?.name.as_str()) != Some(Scheme::Bearer) {
223        return None;
224    }
225    let secret = base.scheme_arg("secret")?.value.as_str()?.to_string();
226    let TypeRef::Named(id) = base.identity.as_ref()? else {
227        return None;
228    };
229    Some(BearerSeam {
230        binder: by.binder.as_ref().map(|b| b.name.clone()),
231        secret,
232        identity_type: id.name.clone(),
233        authorization,
234    })
235}
236
237/// v0.151: the data the emitter needs to lower an OIDC/JWKS verification seam —
238/// the `by` binder (or `None` for the verify-and-discard form), the public
239/// trust parameters (`issuer`, `audience`, `jwks` URL), and the identity type
240/// to construct from the verified `sub` claim. Resolved only for a handler
241/// whose `by` clause names a local `Oidc` actor. Unlike Bearer/Signature, the
242/// seam carries **no secret env name** — the trust root is the provider's
243/// published public key set (`jwks`).
244#[derive(Debug, Clone)]
245pub struct OidcSeam {
246    /// The identity binder, or `None` for `by <OidcActor>` (verify, don't
247    /// capture the identity). When `None` the seam still verifies fail-closed
248    /// but mints no identity and threads nothing into `deps`.
249    pub binder: Option<String>,
250    /// The expected `iss` claim (and the provider whose JWKS anchors trust).
251    pub issuer: String,
252    /// The expected `aud` claim (this API's audience identifier).
253    pub audience: String,
254    /// The JWKS endpoint URL the verifier fetches signing keys from.
255    pub jwks: String,
256    /// The context-owned, string-constructible identity type minted from `sub`.
257    pub identity_type: String,
258}
259
260/// Resolve a handler's OIDC seam, if its `by` clause names a single local
261/// `Oidc` actor. Returns `None` for non-Oidc handlers, for a multi-actor `by`
262/// clause (Oidc is single-actor this slice), and for a refinement (refinement
263/// over Oidc is a later slice) — those follow the existing seam paths. The
264/// checker guarantees `issuer`/`audience`/`jwks` are present and the identity
265/// is a string-constructible local type.
266pub fn oidc_seam_for(handler: &Handler, actors: &HashMap<String, ActorDecl>) -> Option<OidcSeam> {
267    let by = handler.by_clause.as_ref()?;
268    if by.is_sum() {
269        return None;
270    }
271    let actor = actors.get(&by.primary().name)?;
272    // A refinement's `auth` is `None`; it falls through here and follows the
273    // Bearer refinement path (or is rejected). An Oidc base is not narrowed.
274    if Scheme::from_name(actor.auth.as_ref()?.name.as_str()) != Some(Scheme::Oidc) {
275        return None;
276    }
277    let issuer = actor.scheme_arg("issuer")?.value.as_str()?.to_string();
278    let audience = actor.scheme_arg("audience")?.value.as_str()?.to_string();
279    let jwks = actor.scheme_arg("jwks")?.value.as_str()?.to_string();
280    let TypeRef::Named(id) = actor.identity.as_ref()? else {
281        return None;
282    };
283    Some(OidcSeam {
284        binder: by.binder.as_ref().map(|b| b.name.clone()),
285        issuer,
286        audience,
287        jwks,
288        identity_type: id.name.clone(),
289    })
290}
291
292/// v0.54: the binder of a cross-context `on call … by c: Caller` handler that
293/// captures a live `CallerId` (the calling context's name, Q7). `None` unless
294/// the handler binds an identity whose contract is `CallerId` — i.e. the
295/// `Caller` prelude actor (the only source of `CallerId`). A binder-less
296/// `on call` (or one inheriting the `Caller` default) captures nothing and is
297/// unaffected.
298pub fn caller_binder_for(handler: &Handler, actors: &HashMap<String, ActorDecl>) -> Option<String> {
299    // `CallerId` is a cross-context `on call` concept; the checker rejects a
300    // `Caller` actor on other protocols (`scheme_not_admissible`), but guard here
301    // too so the caller seam is never emitted off the call path.
302    if !matches!(handler.kind, HandlerKind::Call) {
303        return None;
304    }
305    let by = handler.by_clause.as_ref()?;
306    let binder = by.binder.as_ref()?;
307    let name = &by.primary().name;
308    // `CallerId` is yielded only by the `Caller` prelude actor; a local actor
309    // never declares it. A binder that collides with a param is suppressed
310    // upstream, mirroring the other seams.
311    let is_caller = !actors.contains_key(name)
312        && prelude_actor(name).map(|c| c.identity) == Some(Identity::CallerId)
313        && !handler.params.iter().any(|p| p.name.name == binder.name);
314    is_caller.then(|| binder.name.clone())
315}
316
317/// v0.51: the data the emitter needs to lower a Signature verification seam —
318/// the signing-secret env name, the signature header, and an optional
319/// timestamp header + tolerance window for replay defence. Resolved only for a
320/// handler whose `by` clause names a local Signature actor.
321#[derive(Debug, Clone)]
322pub struct SignatureSeam {
323    pub secret: String,
324    pub header: String,
325    pub timestamp_header: Option<String>,
326    pub tolerance_secs: Option<i64>,
327}
328
329/// Resolve a handler's Signature seam, if its `by` clause names a local
330/// Signature actor. The checker guarantees `secret` and `header` are present.
331pub fn signature_seam_for(
332    handler: &Handler,
333    actors: &HashMap<String, ActorDecl>,
334) -> Option<SignatureSeam> {
335    let by = handler.by_clause.as_ref()?;
336    let actor = actors.get(&by.primary().name)?;
337    if Scheme::from_name(actor.auth.as_ref()?.name.as_str()) != Some(Scheme::Signature) {
338        return None;
339    }
340    signature_seam_from_decl(actor)
341}
342
343/// The Signature seam data carried by an actor declaration (its keyed config).
344/// Shared by the single-actor `signature_seam_for` and the multi-actor
345/// `sum_members_for`.
346fn signature_seam_from_decl(actor: &ActorDecl) -> Option<SignatureSeam> {
347    Some(SignatureSeam {
348        secret: actor.scheme_arg("secret")?.value.as_str()?.to_string(),
349        header: actor.scheme_arg("header")?.value.as_str()?.to_string(),
350        timestamp_header: actor
351            .scheme_arg("timestamp")
352            .and_then(|a| a.value.as_str())
353            .map(str::to_string),
354        tolerance_secs: actor.scheme_arg("tolerance").and_then(|a| a.value.as_int()),
355    })
356}
357
358/// v0.52: one resolved member of a multi-actor sum — the seam the emitter tries
359/// at that position in the first-wins order. `actor_name` is the variant tag the
360/// body matches on.
361#[derive(Debug, Clone)]
362pub struct SumMember {
363    pub actor_name: String,
364    pub seam: SumMemberSeam,
365}
366
367/// The verification a sum member contributes. `None` (a catch-all such as
368/// `Visitor`) always resolves, so it terminates the order.
369#[derive(Debug, Clone)]
370pub enum SumMemberSeam {
371    None,
372    Bearer {
373        secret: String,
374        identity_type: String,
375    },
376    Signature(SignatureSeam),
377}
378
379impl SumMember {
380    /// Whether resolving this member needs the raw request body read.
381    pub fn needs_body(&self) -> bool {
382        matches!(self.seam, SumMemberSeam::Signature(_))
383    }
384    /// The member's identity type name, if it mints one (Bearer). `None`/
385    /// Signature members carry a unit identity.
386    pub fn identity_type(&self) -> Option<&str> {
387        match &self.seam {
388            SumMemberSeam::Bearer { identity_type, .. } => Some(identity_type),
389            _ => None,
390        }
391    }
392}
393
394/// v0.52: resolve a handler's `by` clause into ordered sum members, if it names
395/// more than one actor. `None` for a single-actor handler (those keep the
396/// existing seam paths). The checker has already validated peer/scheme/
397/// reachability rules; this lowers the verified members for emission.
398pub fn sum_members_for(
399    handler: &Handler,
400    actors: &HashMap<String, ActorDecl>,
401) -> Option<Vec<SumMember>> {
402    let by = handler.by_clause.as_ref()?;
403    if !by.is_sum() {
404        return None;
405    }
406    let mut members = Vec::new();
407    for actor_ref in &by.actors {
408        let seam = if let Some(decl) = actors.get(&actor_ref.name) {
409            match Scheme::from_name(decl.auth.as_ref()?.name.as_str())? {
410                Scheme::None => SumMemberSeam::None,
411                Scheme::Bearer => {
412                    let secret = decl.scheme_arg("secret")?.value.as_str()?.to_string();
413                    let TypeRef::Named(id) = decl.identity.as_ref()? else {
414                        return None;
415                    };
416                    SumMemberSeam::Bearer {
417                        secret,
418                        identity_type: id.name.clone(),
419                    }
420                }
421                Scheme::Signature => SumMemberSeam::Signature(signature_seam_from_decl(decl)?),
422                // v0.151: `Oidc` is single-actor only this slice — the checker
423                // rejects it as a sum member (`bynk.actor.oidc_not_in_sum`), so
424                // a well-formed program never reaches here with an Oidc peer.
425                // Return `None` (fail closed → no sum emission) defensively.
426                Scheme::Oidc => return None,
427                Scheme::Internal => return None,
428            }
429        } else {
430            // A prelude actor: only `Visitor` (scheme `None`) is an HTTP peer.
431            match prelude_actor(&actor_ref.name) {
432                Some(c) if c.scheme == Scheme::None => SumMemberSeam::None,
433                _ => return None,
434            }
435        };
436        members.push(SumMember {
437            actor_name: actor_ref.name.clone(),
438            seam,
439        });
440    }
441    Some(members)
442}
443
444/// Whether `scheme` is admissible on `protocol` (the admissible-scheme-per-
445/// protocol check). HTTP admits `None` (public routes) and `Bearer` (an
446/// `Authorization` header is an HTTP concept); the internal protocols
447/// (call/cron/queue) admit `Internal`. `Signature` is still reserved.
448pub fn scheme_admissible(protocol: &ServiceProtocol, scheme: Scheme) -> bool {
449    match protocol {
450        ServiceProtocol::Http => {
451            matches!(
452                scheme,
453                Scheme::None | Scheme::Bearer | Scheme::Signature | Scheme::Oidc
454            )
455        }
456        // v0.103 (D-B): a WebSocket upgrade authenticates via `None` (anonymous)
457        // or `Bearer` — but the token is read from the `Sec-WebSocket-Protocol`
458        // subprotocol, since a browser `WebSocket` cannot set an `Authorization`
459        // header. `Signature` is rejected at the WS boundary: HMAC-over-body has
460        // no body on a handshake.
461        ServiceProtocol::WebSocket { .. } => {
462            matches!(scheme, Scheme::None | Scheme::Bearer)
463        }
464        // Events track, slice 0 (spine #936): delivery is an internal,
465        // runtime-triggered invocation, like `Call`/`Cron`/`Queue` — no
466        // external network request, so no external auth scheme applies.
467        ServiceProtocol::Call
468        | ServiceProtocol::Cron
469        | ServiceProtocol::Queue { .. }
470        | ServiceProtocol::Events { .. } => {
471            matches!(scheme, Scheme::Internal)
472        }
473    }
474}
475
476/// v0.53: the closed claim-predicate vocabulary for a refinement actor's `where`
477/// clause (`actor Admin = User where hasClaim("admin")`). Claims are untyped
478/// JSON, so the predicate is a closed set — `hasClaim`/`claimEquals` composed
479/// with `&&`/`||`/`!` — checked against the *verified* JWT claims at the
480/// boundary. A general typed-claims expression surface is a later slice.
481#[derive(Debug, Clone)]
482pub enum ClaimPredicate {
483    /// `hasClaim("name")` — the claim is present and truthy.
484    HasClaim(String),
485    /// `claimEquals("name", "value")` — the claim string-equals `value`.
486    ClaimEquals(String, String),
487    And(Box<ClaimPredicate>, Box<ClaimPredicate>),
488    Or(Box<ClaimPredicate>, Box<ClaimPredicate>),
489    Not(Box<ClaimPredicate>),
490}
491
492fn claim_str_lit(e: &Expr) -> Option<String> {
493    match &e.kind {
494        ExprKind::StrLit(s) => Some(s.clone()),
495        _ => None,
496    }
497}
498
499/// Recognise the closed claim-predicate vocabulary in a refinement `where`
500/// expression. `Err(span)` points at the first sub-expression outside the set
501/// (for `bynk.actor.refinement_predicate_unsupported`).
502pub fn parse_claim_predicate(e: &Expr) -> Result<ClaimPredicate, Span> {
503    match &e.kind {
504        ExprKind::Paren(inner) => parse_claim_predicate(inner),
505        ExprKind::BinOp(BinOp::And, l, r) => Ok(ClaimPredicate::And(
506            Box::new(parse_claim_predicate(l)?),
507            Box::new(parse_claim_predicate(r)?),
508        )),
509        ExprKind::BinOp(BinOp::Or, l, r) => Ok(ClaimPredicate::Or(
510            Box::new(parse_claim_predicate(l)?),
511            Box::new(parse_claim_predicate(r)?),
512        )),
513        ExprKind::UnaryOp(UnaryOp::Not, inner) => {
514            Ok(ClaimPredicate::Not(Box::new(parse_claim_predicate(inner)?)))
515        }
516        ExprKind::Call {
517            name,
518            type_args,
519            args,
520        } if type_args.is_empty() => match (name.name.as_str(), args.as_slice()) {
521            ("hasClaim", [a]) => claim_str_lit(a).map(ClaimPredicate::HasClaim).ok_or(a.span),
522            ("claimEquals", [a, b]) => match (claim_str_lit(a), claim_str_lit(b)) {
523                (Some(n), Some(v)) => Ok(ClaimPredicate::ClaimEquals(n, v)),
524                (None, _) => Err(a.span),
525                (_, None) => Err(b.span),
526            },
527            _ => Err(name.span),
528        },
529        _ => Err(e.span),
530    }
531}
532
533/// Lower a claim predicate to a JavaScript boolean expression over `claims_var`
534/// (the verified claims object, `Record<string, unknown>`). Used by the emitter
535/// for the refinement seam's 403 check.
536pub fn claim_predicate_to_js(pred: &ClaimPredicate, claims_var: &str) -> String {
537    match pred {
538        ClaimPredicate::HasClaim(name) => {
539            format!("Boolean({claims_var}[\"{}\"])", js_str_escape(name))
540        }
541        ClaimPredicate::ClaimEquals(name, value) => format!(
542            "({claims_var}[\"{}\"] === \"{}\")",
543            js_str_escape(name),
544            js_str_escape(value)
545        ),
546        ClaimPredicate::And(l, r) => format!(
547            "({} && {})",
548            claim_predicate_to_js(l, claims_var),
549            claim_predicate_to_js(r, claims_var)
550        ),
551        ClaimPredicate::Or(l, r) => format!(
552            "({} || {})",
553            claim_predicate_to_js(l, claims_var),
554            claim_predicate_to_js(r, claims_var)
555        ),
556        ClaimPredicate::Not(inner) => {
557            format!("(!{})", claim_predicate_to_js(inner, claims_var))
558        }
559    }
560}
561
562fn js_str_escape(s: &str) -> String {
563    s.replace('\\', "\\\\").replace('"', "\\\"")
564}