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    ActorDecl, BinOp, Expr, ExprKind, Handler, HandlerKind, ServiceProtocol, TypeRef, UnaryOp,
18};
19use bynk_syntax::span::Span;
20
21/// The authentication scheme — a closed, compiler-known set (ADR Q1). Sealed
22/// now, openable later by widening this enum.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum Scheme {
25    /// Anonymous — no verification; identity is `()`. (`Visitor`.)
26    None,
27    /// In-system / platform trust — the channel itself is the assertion
28    /// (service-binding / platform dispatch). Admitted in Foundations.
29    Internal,
30    /// Bearer token — reserved; not admitted in Foundations.
31    Bearer,
32    /// Request signature — reserved; not admitted in Foundations.
33    Signature,
34}
35
36impl Scheme {
37    /// Classify a scheme name written in `auth = <Scheme>`. `None` means the
38    /// name is not one of the four compiler-known schemes.
39    pub fn from_name(s: &str) -> Option<Scheme> {
40        Some(match s {
41            "None" => Scheme::None,
42            "Internal" => Scheme::Internal,
43            "Bearer" => Scheme::Bearer,
44            "Signature" => Scheme::Signature,
45            _ => return None,
46        })
47    }
48
49    /// The schemes the compiler can emit verification for. v0.45 admitted the
50    /// two zero-crypto schemes (`None`/`Internal`); v0.47 added `Bearer`
51    /// (JWT/HS256); v0.51 adds `Signature` (HMAC over the body). All four
52    /// schemes are now admitted.
53    pub fn admitted(self) -> bool {
54        matches!(
55            self,
56            Scheme::None | Scheme::Internal | Scheme::Bearer | Scheme::Signature
57        )
58    }
59
60    pub fn as_str(self) -> &'static str {
61        match self {
62            Scheme::None => "None",
63            Scheme::Internal => "Internal",
64            Scheme::Bearer => "Bearer",
65            Scheme::Signature => "Signature",
66        }
67    }
68}
69
70/// The identity a verified actor yields (ADR Q2). In Foundations this is `()`
71/// for trivial actors, the built-in sealed `CallerId` for the cross-context
72/// `Internal` channel (Q7, folded in), or a context-owned declared type.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub enum Identity {
75    /// `()` — `None` actors and platform-tag `Internal` actors.
76    Unit,
77    /// The built-in sealed calling-context identity (Q7). Minted at the
78    /// service-binding seam; read-only and never re-checked.
79    CallerId,
80    /// A context-owned declared type named in `identity = <T>`.
81    Declared(String),
82}
83
84/// The built-in sealed identity type for the cross-context calling principal.
85pub const CALLER_ID: &str = "CallerId";
86
87/// A resolved actor contract: its scheme and the identity it yields.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct Contract {
90    pub scheme: Scheme,
91    pub identity: Identity,
92}
93
94/// The prelude actors — compiler-known boundary contracts available without a
95/// declaration. They back the per-protocol defaults and let public HTTP routes
96/// write `by v: Visitor` without ceremony.
97pub fn prelude_actor(name: &str) -> Option<Contract> {
98    Some(match name {
99        // Anonymous public surface — the only safe HTTP actor in Foundations.
100        "Visitor" => Contract {
101            scheme: Scheme::None,
102            identity: Identity::Unit,
103        },
104        // Platform schedulers / producers — Internal, carrying no useful
105        // identity payload (a bare tag).
106        "Scheduler" | "Producer" => Contract {
107            scheme: Scheme::Internal,
108            identity: Identity::Unit,
109        },
110        // The cross-context calling principal — Internal, yielding the sealed
111        // `CallerId` (Q7).
112        "Caller" => Contract {
113            scheme: Scheme::Internal,
114            identity: Identity::CallerId,
115        },
116        _ => return None,
117    })
118}
119
120/// The default actor a handler inherits when it omits `by`, by protocol (ADR
121/// Q5). HTTP has no safe default — `by` is required there.
122pub fn default_actor(protocol: &ServiceProtocol) -> Option<&'static str> {
123    match protocol {
124        ServiceProtocol::Call => Some("Caller"),
125        ServiceProtocol::Cron => Some("Scheduler"),
126        ServiceProtocol::Queue { .. } => Some("Producer"),
127        // v0.103: like HTTP, a WebSocket upgrade has no safe default actor —
128        // `by` is mandatory on `on open` (edge auth before accept, D-A).
129        ServiceProtocol::Http | ServiceProtocol::WebSocket { .. } => None,
130    }
131}
132
133/// v0.47: the data the emitter needs to lower a Bearer verification seam for a
134/// handler — the `by` binder (v0.50: `None` for the binder-less verify-and-
135/// discard form), the signing-secret env name, and the identity type to
136/// construct from the JWT `sub` claim. Resolved only for a handler whose `by`
137/// clause names a local Bearer actor; the checker guarantees the secret is
138/// present and the identity is a string-constructible local type.
139#[derive(Debug, Clone)]
140pub struct BearerSeam {
141    /// The identity binder, or `None` for `by <BearerActor>` (verify the token,
142    /// don't capture the identity). When `None` the seam still verifies fail-
143    /// closed but mints no identity and threads nothing into `deps`.
144    pub binder: Option<String>,
145    pub secret: String,
146    pub identity_type: String,
147    /// v0.53: the authorisation invariant when the `by` actor is a refinement
148    /// (`actor Admin = User where <pred>`). The seam verifies the scheme (401),
149    /// then checks this predicate against the verified claims (403 fail-closed),
150    /// then mints the (base) identity. `None` for a plain Bearer actor.
151    pub authorization: Option<ClaimPredicate>,
152}
153
154/// Resolve a handler's Bearer seam, if its `by` clause names a local Bearer
155/// actor — or a **refinement** of one (v0.53), following the refinement to its
156/// base for the scheme/secret/identity and carrying the authorisation
157/// predicate. Returns `None` for non-Bearer handlers (prelude actors are never
158/// Bearer) — those emit unchanged.
159pub fn bearer_seam_for(
160    handler: &Handler,
161    actors: &HashMap<String, ActorDecl>,
162) -> Option<BearerSeam> {
163    let by = handler.by_clause.as_ref()?;
164    let named = actors.get(&by.primary().name)?;
165    // Follow a refinement to its base; carry the authorisation predicate. The
166    // checker guarantees a refinement's base is Bearer and its predicate parses.
167    let (base, authorization) = match &named.refinement {
168        Some(r) => (
169            actors.get(&r.base.name)?,
170            parse_claim_predicate(&r.predicate).ok(),
171        ),
172        None => (named, None),
173    };
174    if Scheme::from_name(base.auth.as_ref()?.name.as_str()) != Some(Scheme::Bearer) {
175        return None;
176    }
177    let secret = base.scheme_arg("secret")?.value.as_str()?.to_string();
178    let TypeRef::Named(id) = base.identity.as_ref()? else {
179        return None;
180    };
181    Some(BearerSeam {
182        binder: by.binder.as_ref().map(|b| b.name.clone()),
183        secret,
184        identity_type: id.name.clone(),
185        authorization,
186    })
187}
188
189/// v0.54: the binder of a cross-context `on call … by c: Caller` handler that
190/// captures a live `CallerId` (the calling context's name, Q7). `None` unless
191/// the handler binds an identity whose contract is `CallerId` — i.e. the
192/// `Caller` prelude actor (the only source of `CallerId`). A binder-less
193/// `on call` (or one inheriting the `Caller` default) captures nothing and is
194/// unaffected.
195pub fn caller_binder_for(handler: &Handler, actors: &HashMap<String, ActorDecl>) -> Option<String> {
196    // `CallerId` is a cross-context `on call` concept; the checker rejects a
197    // `Caller` actor on other protocols (`scheme_not_admissible`), but guard here
198    // too so the caller seam is never emitted off the call path.
199    if !matches!(handler.kind, HandlerKind::Call) {
200        return None;
201    }
202    let by = handler.by_clause.as_ref()?;
203    let binder = by.binder.as_ref()?;
204    let name = &by.primary().name;
205    // `CallerId` is yielded only by the `Caller` prelude actor; a local actor
206    // never declares it. A binder that collides with a param is suppressed
207    // upstream, mirroring the other seams.
208    let is_caller = !actors.contains_key(name)
209        && prelude_actor(name).map(|c| c.identity) == Some(Identity::CallerId)
210        && !handler.params.iter().any(|p| p.name.name == binder.name);
211    is_caller.then(|| binder.name.clone())
212}
213
214/// v0.51: the data the emitter needs to lower a Signature verification seam —
215/// the signing-secret env name, the signature header, and an optional
216/// timestamp header + tolerance window for replay defence. Resolved only for a
217/// handler whose `by` clause names a local Signature actor.
218#[derive(Debug, Clone)]
219pub struct SignatureSeam {
220    pub secret: String,
221    pub header: String,
222    pub timestamp_header: Option<String>,
223    pub tolerance_secs: Option<i64>,
224}
225
226/// Resolve a handler's Signature seam, if its `by` clause names a local
227/// Signature actor. The checker guarantees `secret` and `header` are present.
228pub fn signature_seam_for(
229    handler: &Handler,
230    actors: &HashMap<String, ActorDecl>,
231) -> Option<SignatureSeam> {
232    let by = handler.by_clause.as_ref()?;
233    let actor = actors.get(&by.primary().name)?;
234    if Scheme::from_name(actor.auth.as_ref()?.name.as_str()) != Some(Scheme::Signature) {
235        return None;
236    }
237    signature_seam_from_decl(actor)
238}
239
240/// The Signature seam data carried by an actor declaration (its keyed config).
241/// Shared by the single-actor `signature_seam_for` and the multi-actor
242/// `sum_members_for`.
243fn signature_seam_from_decl(actor: &ActorDecl) -> Option<SignatureSeam> {
244    Some(SignatureSeam {
245        secret: actor.scheme_arg("secret")?.value.as_str()?.to_string(),
246        header: actor.scheme_arg("header")?.value.as_str()?.to_string(),
247        timestamp_header: actor
248            .scheme_arg("timestamp")
249            .and_then(|a| a.value.as_str())
250            .map(str::to_string),
251        tolerance_secs: actor.scheme_arg("tolerance").and_then(|a| a.value.as_int()),
252    })
253}
254
255/// v0.52: one resolved member of a multi-actor sum — the seam the emitter tries
256/// at that position in the first-wins order. `actor_name` is the variant tag the
257/// body matches on.
258#[derive(Debug, Clone)]
259pub struct SumMember {
260    pub actor_name: String,
261    pub seam: SumMemberSeam,
262}
263
264/// The verification a sum member contributes. `None` (a catch-all such as
265/// `Visitor`) always resolves, so it terminates the order.
266#[derive(Debug, Clone)]
267pub enum SumMemberSeam {
268    None,
269    Bearer {
270        secret: String,
271        identity_type: String,
272    },
273    Signature(SignatureSeam),
274}
275
276impl SumMember {
277    /// Whether resolving this member needs the raw request body read.
278    pub fn needs_body(&self) -> bool {
279        matches!(self.seam, SumMemberSeam::Signature(_))
280    }
281    /// The member's identity type name, if it mints one (Bearer). `None`/
282    /// Signature members carry a unit identity.
283    pub fn identity_type(&self) -> Option<&str> {
284        match &self.seam {
285            SumMemberSeam::Bearer { identity_type, .. } => Some(identity_type),
286            _ => None,
287        }
288    }
289}
290
291/// v0.52: resolve a handler's `by` clause into ordered sum members, if it names
292/// more than one actor. `None` for a single-actor handler (those keep the
293/// existing seam paths). The checker has already validated peer/scheme/
294/// reachability rules; this lowers the verified members for emission.
295pub fn sum_members_for(
296    handler: &Handler,
297    actors: &HashMap<String, ActorDecl>,
298) -> Option<Vec<SumMember>> {
299    let by = handler.by_clause.as_ref()?;
300    if !by.is_sum() {
301        return None;
302    }
303    let mut members = Vec::new();
304    for actor_ref in &by.actors {
305        let seam = if let Some(decl) = actors.get(&actor_ref.name) {
306            match Scheme::from_name(decl.auth.as_ref()?.name.as_str())? {
307                Scheme::None => SumMemberSeam::None,
308                Scheme::Bearer => {
309                    let secret = decl.scheme_arg("secret")?.value.as_str()?.to_string();
310                    let TypeRef::Named(id) = decl.identity.as_ref()? else {
311                        return None;
312                    };
313                    SumMemberSeam::Bearer {
314                        secret,
315                        identity_type: id.name.clone(),
316                    }
317                }
318                Scheme::Signature => SumMemberSeam::Signature(signature_seam_from_decl(decl)?),
319                Scheme::Internal => return None,
320            }
321        } else {
322            // A prelude actor: only `Visitor` (scheme `None`) is an HTTP peer.
323            match prelude_actor(&actor_ref.name) {
324                Some(c) if c.scheme == Scheme::None => SumMemberSeam::None,
325                _ => return None,
326            }
327        };
328        members.push(SumMember {
329            actor_name: actor_ref.name.clone(),
330            seam,
331        });
332    }
333    Some(members)
334}
335
336/// Whether `scheme` is admissible on `protocol` (the admissible-scheme-per-
337/// protocol check). HTTP admits `None` (public routes) and `Bearer` (an
338/// `Authorization` header is an HTTP concept); the internal protocols
339/// (call/cron/queue) admit `Internal`. `Signature` is still reserved.
340pub fn scheme_admissible(protocol: &ServiceProtocol, scheme: Scheme) -> bool {
341    match protocol {
342        ServiceProtocol::Http => {
343            matches!(scheme, Scheme::None | Scheme::Bearer | Scheme::Signature)
344        }
345        // v0.103 (D-B): a WebSocket upgrade authenticates via `None` (anonymous)
346        // or `Bearer` — but the token is read from the `Sec-WebSocket-Protocol`
347        // subprotocol, since a browser `WebSocket` cannot set an `Authorization`
348        // header. `Signature` is rejected at the WS boundary: HMAC-over-body has
349        // no body on a handshake.
350        ServiceProtocol::WebSocket { .. } => {
351            matches!(scheme, Scheme::None | Scheme::Bearer)
352        }
353        ServiceProtocol::Call | ServiceProtocol::Cron | ServiceProtocol::Queue { .. } => {
354            matches!(scheme, Scheme::Internal)
355        }
356    }
357}
358
359/// v0.53: the closed claim-predicate vocabulary for a refinement actor's `where`
360/// clause (`actor Admin = User where hasClaim("admin")`). Claims are untyped
361/// JSON, so the predicate is a closed set — `hasClaim`/`claimEquals` composed
362/// with `&&`/`||`/`!` — checked against the *verified* JWT claims at the
363/// boundary. A general typed-claims expression surface is a later slice.
364#[derive(Debug, Clone)]
365pub enum ClaimPredicate {
366    /// `hasClaim("name")` — the claim is present and truthy.
367    HasClaim(String),
368    /// `claimEquals("name", "value")` — the claim string-equals `value`.
369    ClaimEquals(String, String),
370    And(Box<ClaimPredicate>, Box<ClaimPredicate>),
371    Or(Box<ClaimPredicate>, Box<ClaimPredicate>),
372    Not(Box<ClaimPredicate>),
373}
374
375fn claim_str_lit(e: &Expr) -> Option<String> {
376    match &e.kind {
377        ExprKind::StrLit(s) => Some(s.clone()),
378        _ => None,
379    }
380}
381
382/// Recognise the closed claim-predicate vocabulary in a refinement `where`
383/// expression. `Err(span)` points at the first sub-expression outside the set
384/// (for `bynk.actor.refinement_predicate_unsupported`).
385pub fn parse_claim_predicate(e: &Expr) -> Result<ClaimPredicate, Span> {
386    match &e.kind {
387        ExprKind::Paren(inner) => parse_claim_predicate(inner),
388        ExprKind::BinOp(BinOp::And, l, r) => Ok(ClaimPredicate::And(
389            Box::new(parse_claim_predicate(l)?),
390            Box::new(parse_claim_predicate(r)?),
391        )),
392        ExprKind::BinOp(BinOp::Or, l, r) => Ok(ClaimPredicate::Or(
393            Box::new(parse_claim_predicate(l)?),
394            Box::new(parse_claim_predicate(r)?),
395        )),
396        ExprKind::UnaryOp(UnaryOp::Not, inner) => {
397            Ok(ClaimPredicate::Not(Box::new(parse_claim_predicate(inner)?)))
398        }
399        ExprKind::Call {
400            name,
401            type_args,
402            args,
403        } if type_args.is_empty() => match (name.name.as_str(), args.as_slice()) {
404            ("hasClaim", [a]) => claim_str_lit(a).map(ClaimPredicate::HasClaim).ok_or(a.span),
405            ("claimEquals", [a, b]) => match (claim_str_lit(a), claim_str_lit(b)) {
406                (Some(n), Some(v)) => Ok(ClaimPredicate::ClaimEquals(n, v)),
407                (None, _) => Err(a.span),
408                (_, None) => Err(b.span),
409            },
410            _ => Err(name.span),
411        },
412        _ => Err(e.span),
413    }
414}
415
416/// Lower a claim predicate to a JavaScript boolean expression over `claims_var`
417/// (the verified claims object, `Record<string, unknown>`). Used by the emitter
418/// for the refinement seam's 403 check.
419pub fn claim_predicate_to_js(pred: &ClaimPredicate, claims_var: &str) -> String {
420    match pred {
421        ClaimPredicate::HasClaim(name) => {
422            format!("Boolean({claims_var}[\"{}\"])", js_str_escape(name))
423        }
424        ClaimPredicate::ClaimEquals(name, value) => format!(
425            "({claims_var}[\"{}\"] === \"{}\")",
426            js_str_escape(name),
427            js_str_escape(value)
428        ),
429        ClaimPredicate::And(l, r) => format!(
430            "({} && {})",
431            claim_predicate_to_js(l, claims_var),
432            claim_predicate_to_js(r, claims_var)
433        ),
434        ClaimPredicate::Or(l, r) => format!(
435            "({} || {})",
436            claim_predicate_to_js(l, claims_var),
437            claim_predicate_to_js(r, claims_var)
438        ),
439        ClaimPredicate::Not(inner) => {
440            format!("(!{})", claim_predicate_to_js(inner, claims_var))
441        }
442    }
443}
444
445fn js_str_escape(s: &str) -> String {
446    s.replace('\\', "\\\\").replace('"', "\\\"")
447}