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}