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