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::{BTreeSet, 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    /// I3 (#1971, interop track D2): a caller that is not Bynk, declared by the
54    /// context it calls (`auth = Foreign(secret = "LEGACY_CALL_KEY")`). It signs
55    /// a call like a Bynk caller does (`bynk_check::wire_format`), naming
56    /// itself by the actor's name, under the declared secret. Admitted on
57    /// `call` only, and resolves to its own actor, never to `Caller`.
58    Foreign,
59}
60
61impl Scheme {
62    /// Classify a scheme name written in `auth = <Scheme>`. `None` means the
63    /// name is not one of the compiler-known schemes.
64    pub fn from_name(s: &str) -> Option<Scheme> {
65        Some(match s {
66            "None" => Scheme::None,
67            "Internal" => Scheme::Internal,
68            "Bearer" => Scheme::Bearer,
69            "Signature" => Scheme::Signature,
70            "Oidc" => Scheme::Oidc,
71            "Foreign" => Scheme::Foreign,
72            _ => return None,
73        })
74    }
75
76    /// The schemes the compiler can emit verification for. v0.45 admitted the
77    /// two zero-crypto schemes (`None`/`Internal`); v0.47 added `Bearer`
78    /// (JWT/HS256); v0.51 added `Signature` (HMAC over the body); v0.151 adds
79    /// `Oidc` (JWKS/RS256+ES256). All five schemes are now admitted.
80    pub fn admitted(self) -> bool {
81        matches!(
82            self,
83            Scheme::None
84                | Scheme::Internal
85                | Scheme::Bearer
86                | Scheme::Signature
87                | Scheme::Oidc
88                | Scheme::Foreign
89        )
90    }
91
92    pub fn as_str(self) -> &'static str {
93        match self {
94            Scheme::None => "None",
95            Scheme::Internal => "Internal",
96            Scheme::Bearer => "Bearer",
97            Scheme::Signature => "Signature",
98            Scheme::Oidc => "Oidc",
99            Scheme::Foreign => "Foreign",
100        }
101    }
102}
103
104/// The identity a verified actor yields (ADR Q2). In Foundations this is `()`
105/// for trivial actors, the built-in sealed `CallerId` for the cross-context
106/// `Internal` channel (Q7, folded in), or a context-owned declared type.
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub enum Identity {
109    /// `()` — `None` actors and platform-tag `Internal` actors.
110    Unit,
111    /// The built-in sealed calling-context identity (Q7). Minted at the
112    /// service-binding seam; read-only and never re-checked.
113    CallerId,
114    /// A context-owned declared type named in `identity = <T>`.
115    Declared(String),
116}
117
118/// The built-in sealed identity type for the cross-context calling principal.
119pub const CALLER_ID: &str = "CallerId";
120
121/// A resolved actor contract: its scheme and the identity it yields.
122#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct Contract {
124    pub scheme: Scheme,
125    pub identity: Identity,
126}
127
128/// The prelude actors — compiler-known boundary contracts available without a
129/// declaration. They back the per-protocol defaults and let public HTTP routes
130/// write `by v: Visitor` without ceremony.
131pub fn prelude_actor(name: &str) -> Option<Contract> {
132    Some(match name {
133        // Anonymous public surface — the only safe HTTP actor in Foundations.
134        "Visitor" => Contract {
135            scheme: Scheme::None,
136            identity: Identity::Unit,
137        },
138        // Platform schedulers / producers — Internal, carrying no useful
139        // identity payload (a bare tag).
140        "Scheduler" | "Producer" => Contract {
141            scheme: Scheme::Internal,
142            identity: Identity::Unit,
143        },
144        // The cross-context calling principal — Internal, yielding the sealed
145        // `CallerId` (Q7).
146        "Caller" => Contract {
147            scheme: Scheme::Internal,
148            identity: Identity::CallerId,
149        },
150        _ => return None,
151    })
152}
153
154/// The default actor a handler inherits when it omits `by`, by protocol (ADR
155/// Q5). HTTP has no safe default — `by` is required there.
156pub fn default_actor(protocol: &ServiceProtocol) -> Option<&'static str> {
157    match protocol {
158        ServiceProtocol::Call => Some("Caller"),
159        ServiceProtocol::Cron => Some("Scheduler"),
160        ServiceProtocol::Queue { .. } => Some("Producer"),
161        // Events track, slice 0 (spine #936): delivery is runtime-triggered,
162        // not an external caller — like `Queue`'s "Producer", the default
163        // actor names the event's originating publisher.
164        ServiceProtocol::Events { .. } => Some("Publisher"),
165        // v0.103: like HTTP, a WebSocket upgrade has no safe default actor —
166        // `by` is mandatory on `on open` (edge auth before accept, D-A).
167        ServiceProtocol::Http | ServiceProtocol::WebSocket { .. } => None,
168    }
169}
170
171/// v0.47: the data the emitter needs to lower a Bearer verification seam for a
172/// handler — the `by` binder (v0.50: `None` for the binder-less verify-and-
173/// discard form), the signing-secret env name, and the identity type to
174/// construct from the JWT `sub` claim. Resolved only for a handler whose `by`
175/// clause names a local Bearer actor; the checker guarantees the secret is
176/// present and the identity is a string-constructible local type.
177#[derive(Debug, Clone)]
178pub struct BearerSeam {
179    /// The identity binder, or `None` for `by <BearerActor>` (verify the token,
180    /// don't capture the identity). When `None` the seam still verifies fail-
181    /// closed but mints no identity and threads nothing into `deps`.
182    pub binder: Option<String>,
183    pub secret: String,
184    pub identity_type: String,
185    /// v0.53: the authorisation invariant when the `by` actor is a refinement
186    /// (`actor Admin = User where <pred>`). The seam verifies the scheme (401),
187    /// then checks this predicate against the verified claims (403 fail-closed),
188    /// then mints the (base) identity. `None` for a plain Bearer actor.
189    pub authorization: Option<ClaimPredicate>,
190}
191
192/// Resolve a handler's Bearer seam, if its `by` clause names a local Bearer
193/// actor — or a **refinement** of one (v0.53), following the refinement to its
194/// base for the scheme/secret/identity and carrying the authorisation
195/// predicate. Returns `None` for non-Bearer handlers (prelude actors are never
196/// Bearer) — those emit unchanged.
197/// #706: whether a `by` clause names a **Bearer**-secured actor (following a
198/// refinement to its base). The routes for which `by Nobody` can drive the auth
199/// seam to a `401` — an unsecured (`Visitor`/`None`) or `Signature`/`Oidc` route
200/// has no Bearer seam the test driver knows how to leave unauthenticated, so
201/// `by Nobody` there is rejected (`bynk.test.nobody_needs_secured_route`). The
202/// scheme check mirrors [`bearer_seam_for`].
203pub fn by_clause_is_bearer(by: &ByClause, actors: &HashMap<String, ActorDecl>) -> bool {
204    let Some(named) = actors.get(&by.primary().name) else {
205        return false;
206    };
207    let base = match &named.refinement {
208        Some(r) => match actors.get(&r.base.name) {
209            Some(b) => b,
210            None => return false,
211        },
212        None => named,
213    };
214    base.auth
215        .as_ref()
216        .and_then(|a| Scheme::from_name(a.name.as_str()))
217        == Some(Scheme::Bearer)
218}
219
220pub fn bearer_seam_for(
221    handler: &Handler,
222    actors: &HashMap<String, ActorDecl>,
223) -> Option<BearerSeam> {
224    let by = handler.by_clause.as_ref()?;
225    let named = actors.get(&by.primary().name)?;
226    // Follow a refinement to its base; carry the authorisation predicate. The
227    // checker guarantees a refinement's base is Bearer and its predicate parses.
228    let (base, authorization) = match &named.refinement {
229        Some(r) => (
230            actors.get(&r.base.name)?,
231            parse_claim_predicate(&r.predicate).ok(),
232        ),
233        None => (named, None),
234    };
235    if Scheme::from_name(base.auth.as_ref()?.name.as_str()) != Some(Scheme::Bearer) {
236        return None;
237    }
238    let secret = base.scheme_arg("secret")?.value.as_str()?.to_string();
239    let TypeRef::Named(id) = base.identity.as_ref()? else {
240        return None;
241    };
242    Some(BearerSeam {
243        binder: by.binder.as_ref().map(|b| b.name.clone()),
244        secret,
245        identity_type: id.name.clone(),
246        authorization,
247    })
248}
249
250/// v0.151: the data the emitter needs to lower an OIDC/JWKS verification seam —
251/// the `by` binder (or `None` for the verify-and-discard form), the public
252/// trust parameters (`issuer`, `audience`, `jwks` URL), and the identity type
253/// to construct from the verified `sub` claim. Resolved only for a handler
254/// whose `by` clause names a local `Oidc` actor. Unlike Bearer/Signature, the
255/// seam carries **no secret env name** — the trust root is the provider's
256/// published public key set (`jwks`).
257#[derive(Debug, Clone)]
258pub struct OidcSeam {
259    /// The identity binder, or `None` for `by <OidcActor>` (verify, don't
260    /// capture the identity). When `None` the seam still verifies fail-closed
261    /// but mints no identity and threads nothing into `deps`.
262    pub binder: Option<String>,
263    /// The expected `iss` claim (and the provider whose JWKS anchors trust).
264    pub issuer: String,
265    /// The expected `aud` claim (this API's audience identifier).
266    pub audience: String,
267    /// The JWKS endpoint URL the verifier fetches signing keys from.
268    pub jwks: String,
269    /// The context-owned, string-constructible identity type minted from `sub`.
270    pub identity_type: String,
271    /// #2380 (L5): the environment variable whose JSON, when set, overrides
272    /// `issuer`/`audience`/`jwks` at run time.
273    pub trust_from: Option<String>,
274}
275
276/// Resolve a handler's OIDC seam, if its `by` clause names a single local
277/// `Oidc` actor. Returns `None` for non-Oidc handlers, for a multi-actor `by`
278/// clause (Oidc is single-actor this slice), and for a refinement (refinement
279/// over Oidc is a later slice) — those follow the existing seam paths. The
280/// checker guarantees `issuer`/`audience`/`jwks` are present and the identity
281/// is a string-constructible local type.
282pub fn oidc_seam_for(handler: &Handler, actors: &HashMap<String, ActorDecl>) -> Option<OidcSeam> {
283    let by = handler.by_clause.as_ref()?;
284    if by.is_sum() {
285        return None;
286    }
287    let actor = actors.get(&by.primary().name)?;
288    // A refinement's `auth` is `None`; it falls through here and follows the
289    // Bearer refinement path (or is rejected). An Oidc base is not narrowed.
290    if Scheme::from_name(actor.auth.as_ref()?.name.as_str()) != Some(Scheme::Oidc) {
291        return None;
292    }
293    let issuer = actor.scheme_arg("issuer")?.value.as_str()?.to_string();
294    let audience = actor.scheme_arg("audience")?.value.as_str()?.to_string();
295    let jwks = actor.scheme_arg("jwks")?.value.as_str()?.to_string();
296    let TypeRef::Named(id) = actor.identity.as_ref()? else {
297        return None;
298    };
299    Some(OidcSeam {
300        binder: by.binder.as_ref().map(|b| b.name.clone()),
301        issuer,
302        audience,
303        jwks,
304        identity_type: id.name.clone(),
305        trust_from: actor
306            .scheme_arg("trustFrom")
307            .and_then(|a| a.value.as_str())
308            .map(str::to_string),
309    })
310}
311
312/// v0.54: the binder of a cross-context `on call … by c: Caller` handler that
313/// captures a live `CallerId` (the calling context's name, Q7). `None` unless
314/// the handler binds an identity whose contract is `CallerId` — i.e. the
315/// `Caller` prelude actor (the only source of `CallerId`). A binder-less
316/// `on call` (or one inheriting the `Caller` default) captures nothing and is
317/// unaffected.
318pub fn caller_binder_for(handler: &Handler, actors: &HashMap<String, ActorDecl>) -> Option<String> {
319    // `CallerId` is a cross-context `on call` concept; the checker rejects a
320    // `Caller` actor on other protocols (`scheme_not_admissible`), but guard here
321    // too so the caller seam is never emitted off the call path.
322    if !matches!(handler.kind, HandlerKind::Call) {
323        return None;
324    }
325    let by = handler.by_clause.as_ref()?;
326    // I3 (#1971): a sum (`by who: Caller | Legacy`) binds the resolved actor,
327    // not a `CallerId`, even when `Caller` is its first member
328    // ([`call_admission_for`]).
329    if by.is_sum() {
330        return None;
331    }
332    let binder = by.binder.as_ref()?;
333    let name = &by.primary().name;
334    // `CallerId` is yielded only by the `Caller` prelude actor; a local actor
335    // never declares it. A binder that collides with a param is suppressed
336    // upstream, mirroring the other seams.
337    let is_caller = !actors.contains_key(name)
338        && prelude_actor(name).map(|c| c.identity) == Some(Identity::CallerId)
339        && !handler.params.iter().any(|p| p.name.name == binder.name);
340    is_caller.then(|| binder.name.clone())
341}
342
343/// I3 (#1971): a declared foreign caller, as a `call` handler admits it: the
344/// actor's name, which the caller sends as `X-Bynk-Caller`, and the secret
345/// the callee verifies its signature under.
346#[derive(Debug, Clone, PartialEq, Eq)]
347pub struct ForeignCaller {
348    pub actor: String,
349    pub secret: String,
350    /// The foreign Worker's name, when the declaration gives one
351    /// (`worker = "ledger-svc"`), so `bynk deploy`'s hand-off can name it.
352    pub worker: Option<String>,
353}
354
355/// I3 (#1971): the foreign caller an actor declaration describes, if it is a
356/// `Foreign`-scheme actor with a `secret`.
357pub fn foreign_caller_of(decl: &ActorDecl) -> Option<ForeignCaller> {
358    (Scheme::from_name(decl.auth.as_ref()?.name.as_str())? == Scheme::Foreign).then_some(())?;
359    Some(ForeignCaller {
360        actor: decl.name.name.clone(),
361        secret: decl.scheme_arg("secret")?.value.as_str()?.to_string(),
362        worker: decl
363            .scheme_arg("worker")
364            .and_then(|a| a.value.as_str())
365            .map(str::to_string),
366    })
367}
368
369/// I3 (#1971): which verified callers an `on call` handler admits. Every call
370/// is authenticated before it reaches a handler (`wire_format`); this is what
371/// the handler then admits, in its `by` clause's order. A Bynk context is
372/// admitted by `Caller` (the default with no `by`) or a local `Internal`
373/// actor; a foreign caller only by its own `Foreign` actor, never by `Caller`.
374#[derive(Debug, Clone, PartialEq, Eq)]
375pub struct CallAdmission {
376    /// Whether a Bynk context is admitted.
377    pub contexts: bool,
378    /// The foreign callers admitted, in `by` order.
379    pub foreign: Vec<ForeignCaller>,
380    /// For a sum, the members in `by` order: `None` for the member that
381    /// admits a context (`Caller`), the foreign caller otherwise. The body's
382    /// `match` reads the resolved one.
383    pub sum: Option<Vec<CallSumMember>>,
384}
385
386/// I3 (#1971): one member of a `call` handler's actor sum.
387#[derive(Debug, Clone, PartialEq, Eq)]
388pub enum CallSumMember {
389    /// `Caller`: a Bynk context, with its name as the identity.
390    Caller,
391    /// A declared foreign caller, with no identity beyond its actor.
392    Foreign(ForeignCaller),
393}
394
395impl CallSumMember {
396    /// The member's tag, the actor's name.
397    pub fn actor_name(&self) -> &str {
398        match self {
399            CallSumMember::Caller => "Caller",
400            CallSumMember::Foreign(f) => &f.actor,
401        }
402    }
403}
404
405/// I3 (#1971): what an `on call` handler admits. `None` off the call path.
406pub fn call_admission_for(
407    handler: &Handler,
408    actors: &HashMap<String, ActorDecl>,
409) -> Option<CallAdmission> {
410    if !matches!(handler.kind, HandlerKind::Call) {
411        return None;
412    }
413    let Some(by) = handler.by_clause.as_ref() else {
414        return Some(CallAdmission {
415            contexts: true,
416            foreign: Vec::new(),
417            sum: None,
418        });
419    };
420    let mut admission = CallAdmission {
421        contexts: false,
422        foreign: Vec::new(),
423        sum: None,
424    };
425    let mut members = Vec::new();
426    for actor in &by.actors {
427        match actors.get(&actor.name) {
428            // Classified by scheme, not by whether the declaration is whole:
429            // a malformed `Foreign` actor (the checker reports it) admits
430            // nobody, rather than falling through to admit contexts.
431            Some(decl)
432                if decl.auth.as_ref().and_then(|a| Scheme::from_name(&a.name))
433                    == Some(Scheme::Foreign) =>
434            {
435                if let Some(f) = foreign_caller_of(decl) {
436                    admission.foreign.push(f.clone());
437                    members.push(CallSumMember::Foreign(f));
438                }
439            }
440            Some(_) => {
441                admission.contexts = true;
442                members.push(CallSumMember::Caller);
443            }
444            None => {
445                admission.contexts = true;
446                members.push(CallSumMember::Caller);
447            }
448        }
449    }
450    if by.is_sum() {
451        admission.sum = Some(members);
452    }
453    Some(admission)
454}
455
456/// v0.51: the data the emitter needs to lower a Signature verification seam —
457/// the signing-secret env name, the signature header, and an optional
458/// timestamp header + tolerance window for replay defence. Resolved only for a
459/// handler whose `by` clause names a local Signature actor.
460#[derive(Debug, Clone)]
461pub struct SignatureSeam {
462    pub secret: String,
463    pub header: String,
464    pub timestamp_header: Option<String>,
465    pub tolerance_secs: Option<i64>,
466}
467
468/// Resolve a handler's Signature seam, if its `by` clause names a local
469/// Signature actor. The checker guarantees `secret` and `header` are present.
470pub fn signature_seam_for(
471    handler: &Handler,
472    actors: &HashMap<String, ActorDecl>,
473) -> Option<SignatureSeam> {
474    signature_seam_for_by(handler.by_clause.as_ref()?, actors)
475}
476
477/// #2155: the `Signature` seam a `by` clause's route is signed with at the
478/// `system` tier — its one actor's, or an actor sum's first `Signature`
479/// member's (what `by Forged`/`by Stale` sign as). `None` when it has none,
480/// and for a sum with an open member ([`sum_has_open_member`]), whose seam
481/// never refuses a bad signature.
482pub fn signature_seam_of_route(
483    by: &ByClause,
484    actors: &HashMap<String, ActorDecl>,
485) -> Option<SignatureSeam> {
486    if !by.is_sum() {
487        return signature_seam_for_by(by, actors);
488    }
489    if sum_has_open_member(by, actors) {
490        return None;
491    }
492    by.actors.iter().find_map(|a| {
493        let decl = actors.get(&a.name)?;
494        (Scheme::from_name(decl.auth.as_ref()?.name.as_str()) == Some(Scheme::Signature))
495            .then(|| signature_seam_from_decl(decl))
496            .flatten()
497    })
498}
499
500/// #2155: whether a `by` clause's route has a credential seam the `system`
501/// driver can leave out (`by Nobody`): a Bearer actor (or a refinement of
502/// one), a `Signature` actor, or an actor sum with such a member and no open
503/// member ([`sum_has_open_member`]).
504pub fn route_is_secured(by: &ByClause, actors: &HashMap<String, ActorDecl>) -> bool {
505    if !by.is_sum() {
506        return by_clause_is_bearer(by, actors) || signature_seam_for_by(by, actors).is_some();
507    }
508    if sum_has_open_member(by, actors) {
509        return false;
510    }
511    by.actors.iter().any(|a| {
512        actors
513            .get(&a.name)
514            .and_then(|d| d.auth.as_ref())
515            .and_then(|auth| Scheme::from_name(auth.name.as_str()))
516            .is_some_and(|s| matches!(s, Scheme::Bearer | Scheme::Signature))
517    })
518}
519
520/// #2155: whether an actor sum has an *open* member — `Visitor`, or a
521/// declared actor with no auth scheme. The sum's seam resolves a request with
522/// a missing or bad credential as that member, so it never answers `401`:
523/// `by Nobody`, `by Forged` and `by Stale` have nothing to drive there.
524pub fn sum_has_open_member(by: &ByClause, actors: &HashMap<String, ActorDecl>) -> bool {
525    by.actors.iter().any(|a| match actors.get(&a.name) {
526        // A refinement narrows a secured base, so it is never open.
527        Some(decl) if decl.refinement.is_some() => false,
528        Some(decl) => decl
529            .auth
530            .as_ref()
531            .and_then(|auth| Scheme::from_name(auth.name.as_str()))
532            .is_none_or(|s| s == Scheme::None),
533        None => prelude_actor(&a.name).is_some_and(|c| c.scheme == Scheme::None),
534    })
535}
536
537/// #2135: [`signature_seam_for`] from the `by` clause alone — the seam of
538/// its primary actor, when that is a local Signature actor.
539pub fn signature_seam_for_by(
540    by: &ByClause,
541    actors: &HashMap<String, ActorDecl>,
542) -> Option<SignatureSeam> {
543    let actor = actors.get(&by.primary().name)?;
544    if Scheme::from_name(actor.auth.as_ref()?.name.as_str()) != Some(Scheme::Signature) {
545        return None;
546    }
547    signature_seam_from_decl(actor)
548}
549
550/// The Signature seam data carried by an actor declaration (its keyed config).
551/// Shared by the single-actor `signature_seam_for` and the multi-actor
552/// `sum_members_for`.
553fn signature_seam_from_decl(actor: &ActorDecl) -> Option<SignatureSeam> {
554    Some(SignatureSeam {
555        secret: actor.scheme_arg("secret")?.value.as_str()?.to_string(),
556        header: actor.scheme_arg("header")?.value.as_str()?.to_string(),
557        timestamp_header: actor
558            .scheme_arg("timestamp")
559            .and_then(|a| a.value.as_str())
560            .map(str::to_string),
561        tolerance_secs: actor.scheme_arg("tolerance").and_then(|a| a.value.as_int()),
562    })
563}
564
565/// v0.52: one resolved member of a multi-actor sum — the seam the emitter tries
566/// at that position in the first-wins order. `actor_name` is the variant tag the
567/// body matches on.
568#[derive(Debug, Clone)]
569pub struct SumMember {
570    pub actor_name: String,
571    pub seam: SumMemberSeam,
572}
573
574/// The verification a sum member contributes. `None` (a catch-all such as
575/// `Visitor`) always resolves, so it terminates the order.
576#[derive(Debug, Clone)]
577pub enum SumMemberSeam {
578    None,
579    Bearer {
580        secret: String,
581        identity_type: String,
582    },
583    Signature(SignatureSeam),
584}
585
586impl SumMember {
587    /// Whether resolving this member needs the raw request body read.
588    pub fn needs_body(&self) -> bool {
589        matches!(self.seam, SumMemberSeam::Signature(_))
590    }
591    /// The member's identity type name, if it mints one (Bearer). `None`/
592    /// Signature members carry a unit identity.
593    pub fn identity_type(&self) -> Option<&str> {
594        match &self.seam {
595            SumMemberSeam::Bearer { identity_type, .. } => Some(identity_type),
596            _ => None,
597        }
598    }
599}
600
601/// v0.52: resolve a handler's `by` clause into ordered sum members, if it names
602/// more than one actor. `None` for a single-actor handler (those keep the
603/// existing seam paths). The checker has already validated peer/scheme/
604/// reachability rules; this lowers the verified members for emission.
605pub fn sum_members_for(
606    handler: &Handler,
607    actors: &HashMap<String, ActorDecl>,
608) -> Option<Vec<SumMember>> {
609    let by = handler.by_clause.as_ref()?;
610    if !by.is_sum() {
611        return None;
612    }
613    let mut members = Vec::new();
614    for actor_ref in &by.actors {
615        let seam = if let Some(decl) = actors.get(&actor_ref.name) {
616            match Scheme::from_name(decl.auth.as_ref()?.name.as_str())? {
617                Scheme::None => SumMemberSeam::None,
618                Scheme::Bearer => {
619                    let secret = decl.scheme_arg("secret")?.value.as_str()?.to_string();
620                    let TypeRef::Named(id) = decl.identity.as_ref()? else {
621                        return None;
622                    };
623                    SumMemberSeam::Bearer {
624                        secret,
625                        identity_type: id.name.clone(),
626                    }
627                }
628                Scheme::Signature => SumMemberSeam::Signature(signature_seam_from_decl(decl)?),
629                // v0.151: `Oidc` is single-actor only this slice — the checker
630                // rejects it as a sum member (`bynk.actor.oidc_not_in_sum`), so
631                // a well-formed program never reaches here with an Oidc peer.
632                // Return `None` (fail closed → no sum emission) defensively.
633                Scheme::Oidc => return None,
634                // I3: a `Foreign` sum is a `call` sum (`call_admission_for`).
635                Scheme::Internal | Scheme::Foreign => return None,
636            }
637        } else {
638            // A prelude actor: only `Visitor` (scheme `None`) is an HTTP peer.
639            match prelude_actor(&actor_ref.name) {
640                Some(c) if c.scheme == Scheme::None => SumMemberSeam::None,
641                _ => return None,
642            }
643        };
644        members.push(SumMember {
645            actor_name: actor_ref.name.clone(),
646            seam,
647        });
648    }
649    Some(members)
650}
651
652/// Whether `scheme` is admissible on `protocol` (the admissible-scheme-per-
653/// protocol check). HTTP admits `None` (public routes) and `Bearer` (an
654/// `Authorization` header is an HTTP concept); the internal protocols
655/// (call/cron/queue) admit `Internal`. `Signature` is still reserved.
656pub fn scheme_admissible(protocol: &ServiceProtocol, scheme: Scheme) -> bool {
657    match protocol {
658        ServiceProtocol::Http => {
659            matches!(
660                scheme,
661                Scheme::None | Scheme::Bearer | Scheme::Signature | Scheme::Oidc
662            )
663        }
664        // v0.103 (D-B): a WebSocket upgrade authenticates via `None` (anonymous)
665        // or `Bearer` — but the token is read from the `Sec-WebSocket-Protocol`
666        // subprotocol, since a browser `WebSocket` cannot set an `Authorization`
667        // header. `Signature` is rejected at the WS boundary: HMAC-over-body has
668        // no body on a handshake.
669        ServiceProtocol::WebSocket { .. } => {
670            matches!(scheme, Scheme::None | Scheme::Bearer)
671        }
672        // Events track, slice 0 (spine #936): delivery is an internal,
673        // runtime-triggered invocation, like `Call`/`Cron`/`Queue` — no
674        // external network request, so no external auth scheme applies.
675        // I3 (#1971): a `call` also admits a declared foreign caller.
676        ServiceProtocol::Call => matches!(scheme, Scheme::Internal | Scheme::Foreign),
677        ServiceProtocol::Cron | ServiceProtocol::Queue { .. } | ServiceProtocol::Events { .. } => {
678            matches!(scheme, Scheme::Internal)
679        }
680    }
681}
682
683/// v0.53: the closed claim-predicate vocabulary for a refinement actor's `where`
684/// clause (`actor Admin = User where hasClaim("admin")`). Claims are untyped
685/// JSON, so the predicate is a closed set — `hasClaim`/`claimEquals` composed
686/// with `&&`/`||`/`!` — checked against the *verified* JWT claims at the
687/// boundary. A general typed-claims expression surface is a later slice.
688#[derive(Debug, Clone)]
689pub enum ClaimPredicate {
690    /// `hasClaim("name")` — the claim is present and truthy.
691    HasClaim(String),
692    /// `claimEquals("name", "value")` — the claim string-equals `value`.
693    ClaimEquals(String, String),
694    And(Box<ClaimPredicate>, Box<ClaimPredicate>),
695    Or(Box<ClaimPredicate>, Box<ClaimPredicate>),
696    Not(Box<ClaimPredicate>),
697}
698
699fn claim_str_lit(e: &Expr) -> Option<String> {
700    match &e.kind {
701        ExprKind::StrLit(s) => Some(s.clone()),
702        _ => None,
703    }
704}
705
706/// Recognise the closed claim-predicate vocabulary in a refinement `where`
707/// expression. `Err(span)` points at the first sub-expression outside the set
708/// (for `bynk.actor.refinement_predicate_unsupported`).
709pub fn parse_claim_predicate(e: &Expr) -> Result<ClaimPredicate, Span> {
710    match &e.kind {
711        ExprKind::Paren(inner) => parse_claim_predicate(inner),
712        ExprKind::BinOp(BinOp::And, l, r) => Ok(ClaimPredicate::And(
713            Box::new(parse_claim_predicate(l)?),
714            Box::new(parse_claim_predicate(r)?),
715        )),
716        ExprKind::BinOp(BinOp::Or, l, r) => Ok(ClaimPredicate::Or(
717            Box::new(parse_claim_predicate(l)?),
718            Box::new(parse_claim_predicate(r)?),
719        )),
720        ExprKind::UnaryOp(UnaryOp::Not, inner) => {
721            Ok(ClaimPredicate::Not(Box::new(parse_claim_predicate(inner)?)))
722        }
723        ExprKind::Call {
724            name,
725            type_args,
726            args,
727        } if type_args.is_empty() => match (name.name.as_str(), args.as_slice()) {
728            ("hasClaim", [a]) => claim_str_lit(a).map(ClaimPredicate::HasClaim).ok_or(a.span),
729            ("claimEquals", [a, b]) => match (claim_str_lit(a), claim_str_lit(b)) {
730                (Some(n), Some(v)) => Ok(ClaimPredicate::ClaimEquals(n, v)),
731                (None, _) => Err(a.span),
732                (_, None) => Err(b.span),
733            },
734            _ => Err(name.span),
735        },
736        _ => Err(e.span),
737    }
738}
739
740/// Lower a claim predicate to a JavaScript boolean expression over `claims_var`
741/// (the verified claims object, `Record<string, unknown>`). Used by the emitter
742/// for the refinement seam's 403 check.
743pub fn claim_predicate_to_js(pred: &ClaimPredicate, claims_var: &str) -> String {
744    match pred {
745        ClaimPredicate::HasClaim(name) => {
746            format!(
747                "globalThis.Boolean({claims_var}[\"{}\"])",
748                js_str_escape(name)
749            )
750        }
751        ClaimPredicate::ClaimEquals(name, value) => format!(
752            "({claims_var}[\"{}\"] === \"{}\")",
753            js_str_escape(name),
754            js_str_escape(value)
755        ),
756        ClaimPredicate::And(l, r) => format!(
757            "({} && {})",
758            claim_predicate_to_js(l, claims_var),
759            claim_predicate_to_js(r, claims_var)
760        ),
761        ClaimPredicate::Or(l, r) => format!(
762            "({} || {})",
763            claim_predicate_to_js(l, claims_var),
764            claim_predicate_to_js(r, claims_var)
765        ),
766        ClaimPredicate::Not(inner) => {
767            format!("(!{})", claim_predicate_to_js(inner, claims_var))
768        }
769    }
770}
771
772fn js_str_escape(s: &str) -> String {
773    s.replace('\\', "\\\\").replace('"', "\\\"")
774}
775
776/// #2019: what a witness claim set requires of one claim. Each need is the
777/// exact set of claim values that meets it, so two needs merge exactly when
778/// their sets intersect. The excluded strings carried by `Truthy`, `Falsy`
779/// and `NotEquals` come from negated `claimEquals`.
780#[derive(Debug, Clone, PartialEq, Eq)]
781enum ClaimNeed {
782    /// String-equal to the value (`claimEquals`).
783    Equals(String),
784    /// Present and truthy (`hasClaim`), and none of these strings.
785    Truthy(BTreeSet<String>),
786    /// Absent or falsy (`!hasClaim`), and none of these strings.
787    Falsy(BTreeSet<String>),
788    /// Anything but these strings (`!claimEquals`), absent included.
789    NotEquals(BTreeSet<String>),
790}
791
792type ClaimSet = std::collections::BTreeMap<String, ClaimNeed>;
793
794/// The intersection of two needs on one claim, or `None` when it is empty.
795fn merge_need(a: &ClaimNeed, b: &ClaimNeed) -> Option<ClaimNeed> {
796    use ClaimNeed::*;
797    let union = |x: &BTreeSet<String>, y: &BTreeSet<String>| x.union(y).cloned().collect();
798    Some(match (a, b) {
799        (Equals(x), Equals(y)) => (x == y).then(|| Equals(x.clone()))?,
800        // A non-empty string is truthy; the empty string is falsy.
801        (Equals(v), Truthy(not)) | (Truthy(not), Equals(v)) => {
802            (!v.is_empty() && !not.contains(v)).then(|| Equals(v.clone()))?
803        }
804        (Equals(v), Falsy(not)) | (Falsy(not), Equals(v)) => {
805            (v.is_empty() && !not.contains(v)).then(|| Equals(v.clone()))?
806        }
807        (Equals(v), NotEquals(not)) | (NotEquals(not), Equals(v)) => {
808            (!not.contains(v)).then(|| Equals(v.clone()))?
809        }
810        (Truthy(_), Falsy(_)) | (Falsy(_), Truthy(_)) => return None,
811        (Truthy(x), Truthy(y) | NotEquals(y)) | (NotEquals(y), Truthy(x)) => Truthy(union(x, y)),
812        (Falsy(x), Falsy(y) | NotEquals(y)) | (NotEquals(y), Falsy(x)) => Falsy(union(x, y)),
813        (NotEquals(x), NotEquals(y)) => NotEquals(union(x, y)),
814    })
815}
816
817/// Merge two claim sets, or `None` when they contradict.
818fn merge_claims(a: &ClaimSet, b: &ClaimSet) -> Option<ClaimSet> {
819    let mut out = a.clone();
820    for (name, need) in b {
821        let merged = match out.get(name) {
822            None => need.clone(),
823            Some(have) => merge_need(have, need)?,
824        };
825        out.insert(name.clone(), merged);
826    }
827    Some(out)
828}
829
830/// Every claim set satisfying `pred` (when `want` is true) or falsifying it,
831/// one per way the `||`/`&&` alternatives can be met. The closed predicate
832/// vocabulary keeps this small.
833fn claim_witnesses(pred: &ClaimPredicate, want: bool) -> Vec<ClaimSet> {
834    let one = |name: &str, need: ClaimNeed| vec![ClaimSet::from([(name.to_string(), need)])];
835    let both = |l: Vec<ClaimSet>, r: Vec<ClaimSet>| {
836        l.iter()
837            .flat_map(|a| r.iter().filter_map(move |b| merge_claims(a, b)))
838            .collect()
839    };
840    let either = |mut l: Vec<ClaimSet>, r: Vec<ClaimSet>| {
841        l.extend(r);
842        l
843    };
844    match (pred, want) {
845        (ClaimPredicate::HasClaim(n), true) => one(n, ClaimNeed::Truthy(BTreeSet::new())),
846        (ClaimPredicate::ClaimEquals(n, v), true) => one(n, ClaimNeed::Equals(v.clone())),
847        (ClaimPredicate::HasClaim(n), false) => one(n, ClaimNeed::Falsy(BTreeSet::new())),
848        (ClaimPredicate::ClaimEquals(n, v), false) => {
849            one(n, ClaimNeed::NotEquals(BTreeSet::from([v.clone()])))
850        }
851        (ClaimPredicate::And(l, r), true) | (ClaimPredicate::Or(l, r), false) => {
852            both(claim_witnesses(l, want), claim_witnesses(r, want))
853        }
854        (ClaimPredicate::Or(l, r), true) | (ClaimPredicate::And(l, r), false) => {
855            either(claim_witnesses(l, want), claim_witnesses(r, want))
856        }
857        (ClaimPredicate::Not(inner), _) => claim_witnesses(inner, !want),
858    }
859}
860
861/// #2019: a JavaScript object literal of JWT claims that satisfies a
862/// refinement actor's predicate, for the `system`-tier test driver to sign
863/// next to `sub` when a case acts as that refinement (`by Admin("root")`):
864/// `hasClaim("admin")` → `{ "admin": true }`, `claimEquals("role", "ops")` →
865/// `{ "role": "ops" }`. The Worker's seam then checks the predicate against
866/// the verified claims exactly as it would a real token's. A `Truthy` need
867/// signs `true`: a boolean is never `===` a string, so it also lies outside
868/// every excluded value. An absent claim meets `Falsy` and `NotEquals`, so
869/// those sign nothing. The search is exact over the closed vocabulary, so
870/// `None` means no claim set satisfies the predicate
871/// (`hasClaim("a") && !hasClaim("a")`).
872pub fn claim_witness_js(pred: &ClaimPredicate) -> Option<String> {
873    let set = claim_witnesses(pred, true).into_iter().next()?;
874    let entries = set
875        .iter()
876        .filter_map(|(name, need)| {
877            let value = match need {
878                ClaimNeed::Truthy(_) => "true".to_string(),
879                ClaimNeed::Equals(v) => format!("\"{}\"", js_str_escape(v)),
880                ClaimNeed::Falsy(_) | ClaimNeed::NotEquals(_) => return None,
881            };
882            Some(format!("\"{}\": {value}", js_str_escape(name)))
883        })
884        .collect::<Vec<_>>();
885    Some(if entries.is_empty() {
886        "{}".to_string()
887    } else {
888        format!("{{ {} }}", entries.join(", "))
889    })
890}
891
892#[cfg(test)]
893mod claim_witness_tests {
894    use super::{ClaimPredicate as P, claim_witness_js};
895
896    fn has(n: &str) -> Box<P> {
897        Box::new(P::HasClaim(n.to_string()))
898    }
899    fn eq(n: &str, v: &str) -> Box<P> {
900        Box::new(P::ClaimEquals(n.to_string(), v.to_string()))
901    }
902
903    #[test]
904    fn a_single_claim_is_signed() {
905        assert_eq!(
906            claim_witness_js(&has("admin")).as_deref(),
907            Some("{ \"admin\": true }")
908        );
909        assert_eq!(
910            claim_witness_js(&eq("role", "ops")).as_deref(),
911            Some("{ \"role\": \"ops\" }")
912        );
913    }
914
915    #[test]
916    fn a_conjunction_signs_both_and_a_disjunction_its_first_arm() {
917        assert_eq!(
918            claim_witness_js(&P::And(has("a"), eq("role", "ops"))).as_deref(),
919            Some("{ \"a\": true, \"role\": \"ops\" }")
920        );
921        assert_eq!(
922            claim_witness_js(&P::Or(has("a"), has("b"))).as_deref(),
923            Some("{ \"a\": true }")
924        );
925    }
926
927    #[test]
928    fn a_negation_leaves_the_claim_out_and_steers_the_disjunction() {
929        assert_eq!(
930            claim_witness_js(&P::Not(has("banned"))).as_deref(),
931            Some("{}")
932        );
933        // `(a || b) && !a` is met only by `b`.
934        assert_eq!(
935            claim_witness_js(&P::And(
936                Box::new(P::Or(has("a"), has("b"))),
937                Box::new(P::Not(has("a")))
938            ))
939            .as_deref(),
940            Some("{ \"b\": true }")
941        );
942        // `!(a && b)` is met by leaving `a` out.
943        assert_eq!(
944            claim_witness_js(&P::Not(Box::new(P::And(has("a"), has("b"))))).as_deref(),
945            Some("{}")
946        );
947    }
948
949    #[test]
950    fn a_negated_equality_excludes_one_value_only() {
951        let not = |p: Box<P>| Box::new(P::Not(p));
952        // `hasClaim("role") && !claimEquals("role", "banned")`: any truthy
953        // non-"banned" value; `true` is never `===` a string.
954        assert_eq!(
955            claim_witness_js(&P::And(has("role"), not(eq("role", "banned")))).as_deref(),
956            Some("{ \"role\": true }")
957        );
958        // `claimEquals("role", "ops") && !claimEquals("role", "admin")`.
959        assert_eq!(
960            claim_witness_js(&P::And(eq("role", "ops"), not(eq("role", "admin")))).as_deref(),
961            Some("{ \"role\": \"ops\" }")
962        );
963        // `!claimEquals("role", "banned")` alone: leave the claim out.
964        assert_eq!(
965            claim_witness_js(&P::Not(eq("role", "banned"))).as_deref(),
966            Some("{}")
967        );
968        // `!claimEquals("r", "x") && !hasClaim("r")`: absent meets both.
969        assert_eq!(
970            claim_witness_js(&P::And(not(eq("r", "x")), not(has("r")))).as_deref(),
971            Some("{}")
972        );
973    }
974
975    #[test]
976    fn an_excluded_value_still_contradicts_its_own_equality() {
977        let not = |p: Box<P>| Box::new(P::Not(p));
978        // `hasClaim("r") && !claimEquals("r", "x") && claimEquals("r", "x")`.
979        assert_eq!(
980            claim_witness_js(&P::And(
981                Box::new(P::And(has("r"), not(eq("r", "x")))),
982                eq("r", "x")
983            )),
984            None
985        );
986        // An empty string is falsy, so it cannot meet `hasClaim`.
987        assert_eq!(claim_witness_js(&P::And(has("r"), eq("r", ""))), None);
988        assert_eq!(
989            claim_witness_js(&P::And(not(has("r")), eq("r", ""))).as_deref(),
990            Some("{ \"r\": \"\" }")
991        );
992    }
993
994    #[test]
995    fn a_contradiction_has_no_witness() {
996        assert_eq!(
997            claim_witness_js(&P::And(has("a"), Box::new(P::Not(has("a"))))),
998            None
999        );
1000        assert_eq!(claim_witness_js(&P::And(eq("r", "x"), eq("r", "y"))), None);
1001    }
1002}