Skip to main content

bynk_emit/emitter/
workers.rs

1//! Per-Worker composition root generation (v0.8 §4.5, v0.9 §5.1).
2//!
3//! Each Worker's `compose.ts` exports a `compose(env)` function that
4//! assembles the context's deps and returns the surface the entry point
5//! invokes — `on call` services for the internal Service Binding protocol
6//! plus `on http` route wrappers for the external HTTP router.
7
8use std::collections::{BTreeSet, HashMap, HashSet};
9use std::fmt::Write as _;
10
11use crate::emitter::http_handler_method_name;
12use crate::emitter::wrangler::{agent_binding_name, consumed_binding_name};
13use crate::project::UnitTable;
14use bynk_syntax::ast::*;
15
16#[allow(clippy::too_many_arguments)]
17pub fn emit_worker_compose(
18    context: &str,
19    table: &UnitTable,
20    consumes: &[String],
21    aliases: &HashMap<String, String>,
22    unit_tables: &HashMap<String, UnitTable>,
23    // v0.17: adapter unit name → its binding module path (relative to the out
24    // root, `.js`). An adapter's external provider class lives in this module,
25    // not in a `handlers.ts`.
26    binding_modules: &HashMap<String, String>,
27    // v0.17: bare flattened capability → the unit it was flattened from.
28    flattened: &HashMap<String, String>,
29    // v0.18: the whole-project consume/alias/flattening maps, so external
30    // provider deps recurse across adapters (spec §4.5).
31    unit_consumes: &HashMap<String, Vec<String>>,
32    unit_consumes_aliases: &HashMap<String, HashMap<String, String>>,
33    unit_flattened: &HashMap<String, HashMap<String, String>>,
34    // v0.19 (C1): this Worker's closure reaches bynk.cloudflare — type the
35    // `KV` namespace into Env (the matching `[[kv_namespaces]]` stanza is
36    // emitted into wrangler.toml).
37    needs_kv: bool,
38) -> String {
39    let mut out = String::new();
40    let _ = writeln!(out, "// Generated by bynkc — do not edit by hand.");
41    let _ = writeln!(out, "// composition root for `{context}` Worker.");
42    writeln!(out).unwrap();
43    // v0.15: cross-context capabilities this Worker uses (in handlers or in a
44    // local provider's `given`) → `deps_key → consumed_context`. Their
45    // providers are instantiated locally (model A1).
46    let cross_caps = worker_cross_caps(table, consumes, aliases, flattened);
47
48    // v0.18: build each cross-cap provider expression up front, recording the
49    // units it references — an external provider's `given` may pull in another
50    // adapter's binding (the transitive given-closure), which must be imported.
51    let mut referenced_units: BTreeSet<String> = BTreeSet::new();
52    let mut cross_cap_exprs: Vec<(String, String)> = Vec::new();
53    for (key, cctx) in &cross_caps {
54        let expr = crate::project::instantiate_provider_expr(
55            cctx,
56            key,
57            unit_tables,
58            unit_consumes,
59            unit_consumes_aliases,
60            unit_flattened,
61            true,
62            Some("env"),
63            &mut referenced_units,
64        );
65        cross_cap_exprs.push((key.clone(), expr));
66    }
67
68    // v0.47/v0.52: a context with a Bearer handler — or a sum with a Bearer
69    // member — imports the JWT verifier; a sum with a Signature member imports
70    // the HMAC verifier. Any verifying wrapper returns `HttpResult` (the 401/400
71    // shaping the entry maps).
72    let sum_handlers: Vec<Vec<bynk_check::actors::SumMember>> = table
73        .services
74        .values()
75        .flat_map(|s| s.handlers.iter())
76        .filter_map(|h| bynk_check::actors::sum_members_for(h, &table.actors))
77        .collect();
78    use bynk_check::actors::SumMemberSeam;
79    let has_bearer = table.services.values().any(|s| {
80        s.handlers
81            .iter()
82            .any(|h| bynk_check::actors::bearer_seam_for(h, &table.actors).is_some())
83    }) || sum_handlers
84        .iter()
85        .flatten()
86        .any(|m| matches!(m.seam, SumMemberSeam::Bearer { .. }));
87    let has_sum_signature = sum_handlers
88        .iter()
89        .flatten()
90        .any(|m| matches!(m.seam, SumMemberSeam::Signature(_)));
91    let has_sum = !sum_handlers.is_empty();
92    // A sum wrapper references `JsonValue` only when it parses a `body`.
93    let sum_parses_body = sum_handlers.iter().flatten().any(|m| m.needs_body())
94        || table
95            .services
96            .values()
97            .flat_map(|s| s.handlers.iter())
98            .any(|h| {
99                bynk_check::actors::sum_members_for(h, &table.actors).is_some()
100                    && h.params.iter().any(|p| p.name.name == "body")
101            });
102    let mut runtime_imports: Vec<&str> = Vec::new();
103    if needs_kv {
104        runtime_imports.push("type KVNamespace");
105    }
106    runtime_imports.push("type ServiceBinding");
107    if has_bearer || has_sum {
108        runtime_imports.push("HttpResult");
109    }
110    if has_bearer {
111        runtime_imports.push("verifyBearerJwtHs256");
112    }
113    if has_sum_signature {
114        runtime_imports.push("verifySignatureHmacSha256");
115    }
116    if sum_parses_body {
117        runtime_imports.push("type JsonValue");
118    }
119    // v0.104 (real-time track slice 3b): a `from WebSocket` upgrade route resolves
120    // the hosting Durable Object by serialising the transfer key, exactly as agent
121    // call sites do.
122    let has_ws_open = table.services.values().any(|s| {
123        s.handlers
124            .iter()
125            .any(|h| matches!(h.kind, HandlerKind::Open))
126    });
127    if has_ws_open {
128        runtime_imports.push("serialiseAgentKey");
129    }
130    let _ = writeln!(
131        out,
132        "import {{ {} }} from \"../../runtime.js\";",
133        runtime_imports.join(", ")
134    );
135    let _ = writeln!(out, "import * as handlers from \"./handlers.js\";");
136    // Import each referenced unit's provider classes. A *context*'s providers
137    // live in its Worker's `handlers.js`; an *adapter*'s external provider
138    // classes live in its binding module at the out root.
139    for cctx in &referenced_units {
140        let ns = cctx.replace('.', "_");
141        if let Some(module) = binding_modules.get(cctx) {
142            let _ = writeln!(out, "import * as {ns}__binding from \"../../{module}\";");
143        } else {
144            let dir = crate::project::worker_dir_name(cctx);
145            let _ = writeln!(
146                out,
147                "import * as handlers_{ns} from \"../{dir}/handlers.js\";"
148            );
149        }
150    }
151    writeln!(out).unwrap();
152
153    // Only consumed *contexts* become Service Bindings — a consumed adapter is
154    // not a Worker (its capability is provided in-process via the binding).
155    let mut sorted_consumes: Vec<&String> = consumes
156        .iter()
157        .filter(|t| !binding_modules.contains_key(*t))
158        .collect();
159    sorted_consumes.sort();
160
161    // Env shape: one Service Binding per consumed context + DO bindings.
162    // v0.19: plus the typed KV namespace when the closure reaches the
163    // cloudflare platform adapter (decision C1 — one fixed `KV` binding).
164    let _ = writeln!(out, "export interface Env {{");
165    for t in &sorted_consumes {
166        let bind = consumed_binding_name(t);
167        let _ = writeln!(out, "  {bind}: ServiceBinding;");
168    }
169    if needs_kv {
170        let _ = writeln!(
171            out,
172            "  {}: KVNamespace;",
173            bynk_check::firstparty::KV_BINDING_NAME
174        );
175    }
176    let mut agent_names: Vec<&String> = table.agents.keys().collect();
177    agent_names.sort();
178    for a in &agent_names {
179        let bind = agent_binding_name(a);
180        let _ = writeln!(out, "  {bind}: DurableObjectNamespace;");
181    }
182    let _ = writeln!(out, "}}");
183    writeln!(out).unwrap();
184
185    if !agent_names.is_empty() {
186        let _ = writeln!(
187            out,
188            "type DurableObjectNamespace = {{ idFromName(name: string): {{ toString(): string }}; get(id: any): any }};"
189        );
190        writeln!(out).unwrap();
191    }
192
193    // v0.79: if any handler in this context uses `~>`, `compose` also takes the
194    // request's execution context and threads its `waitUntil` into `deps.__exec`.
195    let ctx_uses_send = table.services.values().any(|s| {
196        s.handlers
197            .iter()
198            .any(|h| crate::emitter::block_uses_send(&h.body))
199    });
200    let compose_sig = if ctx_uses_send {
201        "export function compose(env: Env, exec: { waitUntil(promise: Promise<unknown>): void }) {"
202    } else {
203        "export function compose(env: Env) {"
204    };
205    let _ = writeln!(out, "{compose_sig}");
206
207    // v0.15: instantiate consumed-unit capability providers locally first, so
208    // local providers (and handlers) can depend on them by their deps key.
209    // v0.18: the expression recursively wires an external provider's `given`
210    // deps (built in the pre-pass above; cross_caps is a BTreeMap, so the
211    // pairs are already key-sorted).
212    let cross_keys: Vec<&String> = cross_caps.keys().collect();
213    for (key, expr) in &cross_cap_exprs {
214        let _ = writeln!(out, "  const {key} = {expr};");
215    }
216
217    // Capabilities: instantiate each capability's provider. v0.12: providers
218    // are emitted in dependency order (a composed provider's `given` deps must
219    // exist first) as local `const` bindings, injecting each provider's deps;
220    // then assembled into the `deps` object. v0.15: a provider's `given` may
221    // include cross-context capability keys (instantiated above).
222    let order = crate::emitter::topo_order_providers(&table.providers);
223    for cap in &order {
224        let provider = table.providers.get(cap).unwrap();
225        let provider_ts = &provider.provider_name.name;
226        if provider.given.is_empty() {
227            let _ = writeln!(out, "  const {cap} = new handlers.{provider_ts}();");
228        } else {
229            let dep_obj = provider
230                .given
231                .iter()
232                .map(|c| c.key().to_string())
233                .collect::<Vec<_>>()
234                .join(", ");
235            let _ = writeln!(
236                out,
237                "  const {cap} = new handlers.{provider_ts}({{ {dep_obj} }});"
238            );
239        }
240    }
241    let mut deps_entries: Vec<String> = {
242        let mut caps: Vec<String> = order.clone();
243        caps.extend(cross_keys.iter().map(|k| (*k).clone()));
244        caps.sort();
245        caps
246    };
247    // env passes through so handlers' cross-context calls (Service Bindings)
248    // and agent instantiations (Durable Object namespaces) can reach it.
249    if !sorted_consumes.is_empty() || !table.agents.is_empty() {
250        deps_entries.push("env".to_string());
251    }
252    // v0.79: the execution context rides in `deps.__exec` for `~>` sends.
253    if ctx_uses_send {
254        deps_entries.push("__exec: exec".to_string());
255    }
256    let _ = writeln!(out, "  const deps = {{ {} }};", deps_entries.join(", "));
257
258    // Local-surface object: one async wrapper per service operation plus
259    // one wrapper per `on http` handler.
260    let mut service_names: Vec<&String> = table.services.keys().collect();
261    service_names.sort();
262    let _ = writeln!(out, "  return {{");
263    for sname in &service_names {
264        let service = table.services.get(*sname).unwrap();
265        let mut cron_idx = 0usize;
266        let mut queue_idx = 0usize;
267        for h in &service.handlers {
268            match &h.kind {
269                HandlerKind::Call => {
270                    emit_call_wrapper(&mut out, sname, h, &table.actors);
271                }
272                HandlerKind::Http { method, path } => {
273                    // v0.52: a multi-actor sum handler gets the first-wins
274                    // resolution wrapper; otherwise the single-actor path.
275                    if let Some(members) = bynk_check::actors::sum_members_for(h, &table.actors) {
276                        emit_http_sum_wrapper(&mut out, sname, h, *method, path, &members);
277                    } else {
278                        let seam = bynk_check::actors::bearer_seam_for(h, &table.actors);
279                        emit_http_wrapper(&mut out, sname, h, *method, path, seam.as_ref());
280                    }
281                }
282                HandlerKind::Cron { .. } => {
283                    emit_cron_wrapper(&mut out, sname, cron_idx, h);
284                    cron_idx += 1;
285                }
286                HandlerKind::Message => {
287                    // v0.106 (slice 3b-iii): a `from WebSocket` `on message` is an
288                    // *inbound* handler that runs in the connection-hosting Durable
289                    // Object (`webSocketMessage`), not at the edge — no compose
290                    // wrapper. A `from queue` `on message` is the queue consumer.
291                    if matches!(service.protocol, ServiceProtocol::WebSocket { .. }) {
292                        continue;
293                    }
294                    emit_queue_wrapper(&mut out, sname, queue_idx, h);
295                    queue_idx += 1;
296                }
297                HandlerKind::Open => {
298                    let seam = bynk_check::actors::bearer_seam_for(h, &table.actors);
299                    let local_agents: HashSet<String> = table.agents.keys().cloned().collect();
300                    emit_websocket_upgrade(&mut out, sname, h, seam.as_ref(), &local_agents);
301                }
302                // v0.106 (slice 3b-iii): `on close` runs in the DO (`webSocketClose`),
303                // not at the edge — no compose wrapper.
304                HandlerKind::Close => {}
305            }
306        }
307    }
308    let _ = writeln!(out, "  }};");
309
310    let _ = writeln!(out, "}}");
311    out
312}
313
314/// v0.15: cross-context capabilities this Worker references in handlers or in
315/// a local provider's `given`, as `deps_key → consumed_context`.
316fn worker_cross_caps(
317    table: &UnitTable,
318    consumes: &[String],
319    aliases: &HashMap<String, String>,
320    flattened: &HashMap<String, String>,
321) -> std::collections::BTreeMap<String, String> {
322    fn resolve(
323        prefix: &str,
324        consumes: &[String],
325        aliases: &HashMap<String, String>,
326    ) -> Option<String> {
327        if let Some(q) = aliases.get(prefix) {
328            return Some(q.clone());
329        }
330        if consumes.iter().any(|c| c == prefix) {
331            return Some(prefix.to_string());
332        }
333        None
334    }
335    let mut out = std::collections::BTreeMap::new();
336    let mut givens: Vec<&[CapRef]> = Vec::new();
337    for s in table.services.values() {
338        for h in &s.handlers {
339            givens.push(&h.given);
340        }
341    }
342    for a in table.agents.values() {
343        for h in &a.handlers {
344            givens.push(&h.given);
345        }
346    }
347    for p in table.providers.values() {
348        givens.push(&p.given);
349    }
350    for given in givens {
351        for c in given {
352            if let Some(p) = c.prefix() {
353                if let Some(ctx) = resolve(&p, consumes, aliases) {
354                    out.entry(c.key().to_string()).or_insert(ctx);
355                }
356            } else if let Some(unit) = flattened.get(c.key()) {
357                // v0.17: a bare flattened capability is provided by its source unit.
358                out.entry(c.key().to_string())
359                    .or_insert_with(|| unit.clone());
360            }
361        }
362    }
363    out
364}
365
366fn emit_call_wrapper(
367    out: &mut String,
368    sname: &str,
369    h: &Handler,
370    actors: &HashMap<String, ActorDecl>,
371) {
372    let mut param_decls: Vec<String> = h
373        .params
374        .iter()
375        .map(|p| format!("{}: any", p.name.name))
376        .collect();
377    let param_args: Vec<String> = h.params.iter().map(|p| p.name.name.clone()).collect();
378    // v0.54: a `by c: Caller` handler's wrapper takes the caller's context name
379    // (read from the header in the entry dispatch) and threads it into `deps`
380    // as the `CallerId` identity — mirroring the Bearer identity threading.
381    let deps_expr = if bynk_check::actors::caller_binder_for(h, actors).is_some() {
382        param_decls.insert(0, "__caller: string".to_string());
383        "{ ...deps, identity: __caller }"
384    } else {
385        "deps"
386    };
387    let _ = writeln!(out, "    async {sname}({}) {{", param_decls.join(", "));
388    let _ = writeln!(
389        out,
390        "      return handlers.{sname}.call({}{}{deps_expr});",
391        param_args.join(", "),
392        if param_args.is_empty() { "" } else { ", " },
393    );
394    let _ = writeln!(out, "    }},");
395}
396
397fn emit_cron_wrapper(out: &mut String, sname: &str, cron_idx: usize, h: &Handler) {
398    let method_key = crate::emitter::cron_handler_method_name(sname, cron_idx);
399    // A cron handler takes an optional scheduled-time parameter; forward it (if
400    // any) to the bound handler, with deps trailing.
401    let param_decls: Vec<String> = h
402        .params
403        .iter()
404        .map(|p| format!("{}: any", p.name.name))
405        .collect();
406    let param_args: Vec<String> = h.params.iter().map(|p| p.name.name.clone()).collect();
407    let _ = writeln!(out, "    async {method_key}({}) {{", param_decls.join(", "));
408    let _ = writeln!(
409        out,
410        "      return handlers.{sname}.{method_key}({}{}deps);",
411        param_args.join(", "),
412        if param_args.is_empty() { "" } else { ", " },
413    );
414    let _ = writeln!(out, "    }},");
415}
416
417fn emit_queue_wrapper(out: &mut String, sname: &str, queue_idx: usize, h: &Handler) {
418    let method_key = crate::emitter::queue_handler_method_name(sname, queue_idx);
419    // The queue handler takes its message parameter; forward it with deps.
420    let param_decls: Vec<String> = h
421        .params
422        .iter()
423        .map(|p| format!("{}: any", p.name.name))
424        .collect();
425    let param_args: Vec<String> = h.params.iter().map(|p| p.name.name.clone()).collect();
426    let _ = writeln!(out, "    async {method_key}({}) {{", param_decls.join(", "));
427    let _ = writeln!(
428        out,
429        "      return handlers.{sname}.{method_key}({}{}deps);",
430        param_args.join(", "),
431        if param_args.is_empty() { "" } else { ", " },
432    );
433    let _ = writeln!(out, "    }},");
434}
435
436/// v0.104 (real-time track slice 3b): emit the WebSocket upgrade route — the
437/// edge half of DECISION A. The upgrade authenticates the actor **in the Worker,
438/// before any request reaches the Durable Object** (the safety boundary): it
439/// reads the Bearer token from the first `Sec-WebSocket-Protocol` element
440/// (DECISION C), verifies it fail-closed with the same audited JWT verifier HTTP
441/// uses, and only on success forwards the upgrade request to the addressed DO —
442/// the agent the `on open` transfers the connection to (DECISION B), keyed by a
443/// request parameter. The verified identity rides in a trusted internal header
444/// (the DO is only reachable through this Worker, the same Internal-channel trust
445/// the cross-context caller seam relies on). A failure returns `401`/`426` and
446/// **does not forward** — no socket is accepted unauthenticated.
447fn emit_websocket_upgrade(
448    out: &mut String,
449    sname: &str,
450    h: &Handler,
451    seam: Option<&bynk_check::actors::BearerSeam>,
452    local_agents: &HashSet<String>,
453) {
454    use crate::emitter::websocket::{WsOpenShape, analyse_open_shape};
455    // The route params (e.g. `roomId`) ride as wrapper arguments — the entry
456    // extracts them from the upgrade URL's query string and passes them through.
457    let mut decls: Vec<String> = vec!["request: Request".to_string()];
458    decls.extend(h.params.iter().map(|p| format!("{}: any", p.name.name)));
459    let _ = writeln!(out, "    async ws_{sname}_open({}) {{", decls.join(", "));
460    // Require an actual WebSocket upgrade before anything else.
461    let _ = writeln!(
462        out,
463        "      if (request.headers.get(\"Upgrade\") !== \"websocket\") return new Response(\"Expected a WebSocket upgrade\", {{ status: 426 }});"
464    );
465
466    // DECISION C: a Bearer token arrives as the first `Sec-WebSocket-Protocol`
467    // subprotocol element (a browser sets it via `new WebSocket(url, [token])`),
468    // verified fail-closed before the request is forwarded. The `on open` requires
469    // a `by` actor, but — exactly as an HTTP `by v: Visitor` route — that actor's
470    // scheme may be `None` (an intentional anonymous channel): then `seam` is
471    // `None` and no token is read. `Signature` is rejected at the WS boundary (a
472    // browser cannot sign the handshake), so a present seam is always Bearer.
473    if let Some(seam) = seam {
474        let secret = seam.secret.replace('\\', "\\\\").replace('"', "\\\"");
475        let _ = writeln!(
476            out,
477            "      const __proto = request.headers.get(\"Sec-WebSocket-Protocol\");"
478        );
479        let _ = writeln!(
480            out,
481            "      if (__proto === null) return new Response(\"Unauthorized\", {{ status: 401 }});"
482        );
483        let _ = writeln!(out, "      const __token = __proto.split(\",\")[0].trim();");
484        // The hosting context's `Env` carries the DO binding (a non-empty object
485        // type), so the secret probe casts through `unknown` to index it.
486        let _ = writeln!(
487            out,
488            "      const __secret = (env as unknown as Record<string, unknown>)[\"{secret}\"] ?? (globalThis as {{ process?: {{ env?: Record<string, unknown> }} }}).process?.env?.[\"{secret}\"];"
489        );
490        let _ = writeln!(
491            out,
492            "      if (typeof __secret !== \"string\") return new Response(\"Unauthorized\", {{ status: 401 }});"
493        );
494        let _ = writeln!(
495            out,
496            "      const __claims = await verifyBearerJwtHs256(__token, __secret);"
497        );
498        let _ = writeln!(
499            out,
500            "      if (__claims.tag === \"Err\") return new Response(\"Unauthorized\", {{ status: 401 }});"
501        );
502        // A refinement actor's authorisation invariant: scheme verified (401
503        // above), a failed claim predicate is 403, checked against verified claims.
504        if let Some(pred) = &seam.authorization {
505            let js = bynk_check::actors::claim_predicate_to_js(pred, "__claims.value.claims");
506            let _ = writeln!(
507                out,
508                "      if (!({js})) return new Response(\"Forbidden\", {{ status: 403 }});"
509            );
510        }
511    }
512
513    // DECISION B: resolve the hosting Durable Object from the single connection
514    // transfer (`Room(roomId)` → the `ROOM` namespace, keyed by `roomId`). The
515    // shape constraint guarantees exactly one routable target.
516    let target = match analyse_open_shape(&h.body, local_agents) {
517        WsOpenShape::One(t) => t,
518        // The checker rejects zero / multiple / non-routable shapes
519        // (`bynk.ws.open_transfer_shape`); this arm is defensive.
520        _ => {
521            let _ = writeln!(
522                out,
523                "      return new Response(\"Internal Server Error\", {{ status: 500 }});"
524            );
525            let _ = writeln!(out, "    }},");
526            return;
527        }
528    };
529    let binding = agent_binding_name(target.agent);
530    let key_js = match &target.key.kind {
531        ExprKind::Ident(id) => id.name.clone(),
532        // v1 keys are request-derivable param idents (DECISION B); a non-ident key
533        // falls back to the first route param so the route stays valid TS.
534        _ => h
535            .params
536            .first()
537            .map(|p| p.name.name.clone())
538            .unwrap_or_else(|| "\"default\"".to_string()),
539    };
540    // The verified identity (when the actor binds one) is forwarded in the trusted
541    // internal header alongside the route arguments. A binder-less `by` verifies
542    // but mints no identity.
543    let identity_field = if seam.is_some_and(|s| s.binder.is_some()) {
544        ", identity: __id.value"
545    } else {
546        ""
547    };
548    if seam.is_some_and(|s| s.binder.is_some()) {
549        let id_ty = &seam.unwrap().identity_type;
550        let _ = writeln!(
551            out,
552            "      const __id = handlers.{id_ty}.of(__claims.value.sub);"
553        );
554        let _ = writeln!(
555            out,
556            "      if (__id.tag === \"Err\") return new Response(\"Unauthorized\", {{ status: 401 }});"
557        );
558    }
559    // The route params arrive attacker-controlled (the upgrade URL's query string).
560    // Validate each refined / opaque param through its `.of` constructor fail-closed
561    // — a `400` with a `RefinementViolation`, exactly as the HTTP path validates a
562    // path param — *before* it addresses a Durable Object or is forwarded to the
563    // on-open body. A malformed value must never reach the DO typed as though it had
564    // satisfied its refinement. (Validation runs after auth, so an unauthenticated
565    // client still sees only `401`.)
566    let mut validated: HashMap<String, String> = HashMap::new();
567    for p in &h.params {
568        let pn = &p.name.name;
569        match &p.type_ref {
570            TypeRef::Named(id) => {
571                let _ = writeln!(out, "      const __r_{pn} = handlers.{}.of({pn});", id.name);
572                let _ = writeln!(
573                    out,
574                    "      if (__r_{pn}.tag === \"Err\") return new Response(JSON.stringify({{ kind: \"RefinementViolation\", path: \"param.{pn}\", violation: __r_{pn}.error }}), {{ status: 400, headers: {{ \"content-type\": \"application/json\" }} }});"
575                );
576                validated.insert(pn.clone(), format!("__r_{pn}.value"));
577            }
578            // A plain `String` param (or a shape the static check already rejected)
579            // passes through unchanged.
580            _ => {
581                validated.insert(pn.clone(), pn.clone());
582            }
583        }
584    }
585    let key_ref = validated.get(&key_js).cloned().unwrap_or(key_js);
586    let args_json = h
587        .params
588        .iter()
589        .map(|p| {
590            validated
591                .get(&p.name.name)
592                .cloned()
593                .unwrap_or_else(|| p.name.name.clone())
594        })
595        .collect::<Vec<_>>()
596        .join(", ");
597    let _ = writeln!(out, "      const __ns = deps.env.{binding};");
598    let _ = writeln!(
599        out,
600        "      const __stub = __ns.get(__ns.idFromName(serialiseAgentKey({key_ref})));"
601    );
602    let _ = writeln!(out, "      const __fwd = new Headers(request.headers);");
603    let _ = writeln!(
604        out,
605        "      __fwd.set(\"X-Bynk-Ws-Open\", JSON.stringify({{ args: [{args_json}]{identity_field} }}));"
606    );
607    let _ = writeln!(
608        out,
609        "      return __stub.fetch(new Request(\"https://_bynk/_bynk/ws/open/{sname}\", {{ method: request.method, headers: __fwd }}));"
610    );
611    let _ = writeln!(out, "    }},");
612}
613
614fn emit_http_wrapper(
615    out: &mut String,
616    sname: &str,
617    h: &Handler,
618    method: HttpMethod,
619    path: &str,
620    seam: Option<&bynk_check::actors::BearerSeam>,
621) {
622    let method_key = http_handler_method_name(method, path);
623    let param_args: Vec<String> = h.params.iter().map(|p| p.name.name.clone()).collect();
624
625    // v0.47: a Bearer handler's wrapper takes the request, runs the fail-closed
626    // verification seam, mints the identity, and threads it into `deps`. The
627    // boundary owns `env` (the secret source) and `deps`, so the whole seam is
628    // one cohesive block here; any failure returns `Unauthorized` (401), which
629    // the entry's `httpResultToResponse` maps. The body never runs unverified.
630    if let Some(seam) = seam {
631        let mut decls: Vec<String> = vec!["request: Request".to_string()];
632        decls.extend(h.params.iter().map(|p| format!("{}: any", p.name.name)));
633        let secret = seam.secret.replace('\\', "\\\\").replace('"', "\\\"");
634        let _ = writeln!(out, "    async {method_key}({}) {{", decls.join(", "));
635        let _ = writeln!(
636            out,
637            "      const __authz = request.headers.get(\"Authorization\");"
638        );
639        let _ = writeln!(
640            out,
641            "      if (__authz === null || !__authz.startsWith(\"Bearer \")) return HttpResult.Unauthorized;"
642        );
643        // Source the signing secret from the same env the `Secrets` capability
644        // reads (explicit env first, then a `process.env` probe).
645        let _ = writeln!(
646            out,
647            "      const __secret = (env as Record<string, unknown>)[\"{secret}\"] ?? (globalThis as {{ process?: {{ env?: Record<string, unknown> }} }}).process?.env?.[\"{secret}\"];"
648        );
649        let _ = writeln!(
650            out,
651            "      if (typeof __secret !== \"string\") return HttpResult.Unauthorized;"
652        );
653        let _ = writeln!(
654            out,
655            "      const __claims = await verifyBearerJwtHs256(__authz.slice(7), __secret);"
656        );
657        let _ = writeln!(
658            out,
659            "      if (__claims.tag === \"Err\") return HttpResult.Unauthorized;"
660        );
661        // v0.53: a refinement actor's authorisation invariant — the scheme
662        // verified (else 401 above), so a failed claim predicate is 403, not
663        // 401. Checked against the *verified* claims, before the identity mints
664        // or the body runs.
665        if let Some(pred) = &seam.authorization {
666            let js = bynk_check::actors::claim_predicate_to_js(pred, "__claims.value.claims");
667            let _ = writeln!(out, "      if (!({js})) return HttpResult.Forbidden;");
668        }
669        if seam.binder.is_some() {
670            // Capture the identity: construct the declared type from `sub`
671            // (fail-closed on a refinement violation) and thread it into deps.
672            let _ = writeln!(
673                out,
674                "      const __id = handlers.{}.of(__claims.value.sub);",
675                seam.identity_type
676            );
677            let _ = writeln!(
678                out,
679                "      if (__id.tag === \"Err\") return HttpResult.Unauthorized;"
680            );
681            let _ = writeln!(
682                out,
683                "      return handlers.{sname}.{method_key}({}{}{{ ...deps, identity: __id.value }});",
684                param_args.join(", "),
685                if param_args.is_empty() { "" } else { ", " },
686            );
687        } else {
688            // Binder-less: the token is verified (fail-closed above); the
689            // identity is not captured, so call the handler with plain deps.
690            let _ = writeln!(
691                out,
692                "      return handlers.{sname}.{method_key}({}{}deps);",
693                param_args.join(", "),
694                if param_args.is_empty() { "" } else { ", " },
695            );
696        }
697        let _ = writeln!(out, "    }},");
698        return;
699    }
700
701    let param_decls: Vec<String> = h
702        .params
703        .iter()
704        .map(|p| format!("{}: any", p.name.name))
705        .collect();
706    let _ = writeln!(out, "    async {method_key}({}) {{", param_decls.join(", "));
707    let _ = writeln!(
708        out,
709        "      return handlers.{sname}.{method_key}({}{}deps);",
710        param_args.join(", "),
711        if param_args.is_empty() { "" } else { ", " },
712    );
713    let _ = writeln!(out, "    }},");
714}
715
716/// Source a string secret from the same env the `Secrets` capability reads
717/// (explicit `env` first, then a `process.env` probe), binding it to `var`.
718fn emit_secret_lookup(out: &mut String, var: &str, secret: &str, indent: &str) {
719    let secret = secret.replace('\\', "\\\\").replace('"', "\\\"");
720    let _ = writeln!(
721        out,
722        "{indent}const {var} = (env as Record<string, unknown>)[\"{secret}\"] ?? (globalThis as {{ process?: {{ env?: Record<string, unknown> }} }}).process?.env?.[\"{secret}\"];"
723    );
724}
725
726/// v0.52: the compose wrapper for a **multi-actor sum** handler (`by who: A |
727/// B`). Unlike the single-actor wrappers, this one owns the *whole* boundary:
728/// it reads the raw body once (when any member needs it or the handler takes a
729/// `body`), tries each member's scheme in declared order, binds the first that
730/// verifies into a tagged `__who`, and — fail-closed → 401 if none verifies —
731/// parses the body and dispatches with `who` threaded through `deps`. The body
732/// `match`es on `who`. The entry passes `request` (+ any path params); no body
733/// read happens in the entry for a sum route.
734fn emit_http_sum_wrapper(
735    out: &mut String,
736    sname: &str,
737    h: &Handler,
738    method: HttpMethod,
739    path: &str,
740    members: &[bynk_check::actors::SumMember],
741) {
742    use bynk_check::actors::SumMemberSeam;
743    let method_key = http_handler_method_name(method, path);
744    // The wrapper takes the request first (it reads the body / headers), then
745    // the path params (parsed in the entry and passed through); the `body`
746    // param is parsed here, not passed in.
747    let path_params: Vec<&String> = h
748        .params
749        .iter()
750        .map(|p| &p.name.name)
751        .filter(|n| *n != "body")
752        .collect();
753    let has_body = h.params.iter().any(|p| p.name.name == "body");
754    let mut decls = vec!["request: Request".to_string()];
755    decls.extend(path_params.iter().map(|n| format!("{n}: any")));
756    let _ = writeln!(out, "    async {method_key}({}) {{", decls.join(", "));
757
758    // Read the raw body once if a member verifies over it (Signature) or the
759    // handler takes a `body` param (parsed from the same bytes).
760    let needs_raw = has_body || members.iter().any(|m| m.needs_body());
761    if needs_raw {
762        let _ = writeln!(out, "      let __raw: string;");
763        let _ = writeln!(out, "      try {{");
764        let _ = writeln!(out, "        __raw = await request.text();");
765        let _ = writeln!(out, "      }} catch {{");
766        let _ = writeln!(
767            out,
768            "        return HttpResult.BadRequest(\"Invalid request body\");"
769        );
770        let _ = writeln!(out, "      }}");
771    }
772
773    // First-wins resolution: try each member in order; the first to verify sets
774    // `__who`. A `None` (catch-all) member always succeeds.
775    let _ = writeln!(out, "      let __who: any = undefined;");
776    for member in members {
777        let tag = member.actor_name.replace('\\', "\\\\").replace('"', "\\\"");
778        let _ = writeln!(out, "      if (__who === undefined) {{");
779        match &member.seam {
780            SumMemberSeam::None => {
781                let _ = writeln!(out, "        __who = {{ tag: \"{tag}\" }};");
782            }
783            SumMemberSeam::Bearer {
784                secret,
785                identity_type,
786            } => {
787                let _ = writeln!(
788                    out,
789                    "        const __authz = request.headers.get(\"Authorization\");"
790                );
791                let _ = writeln!(
792                    out,
793                    "        if (__authz !== null && __authz.startsWith(\"Bearer \")) {{"
794                );
795                emit_secret_lookup(out, "__secret", secret, "          ");
796                let _ = writeln!(out, "          if (typeof __secret === \"string\") {{");
797                let _ = writeln!(
798                    out,
799                    "            const __claims = await verifyBearerJwtHs256(__authz.slice(7), __secret);"
800                );
801                let _ = writeln!(out, "            if (__claims.tag === \"Ok\") {{");
802                let _ = writeln!(
803                    out,
804                    "              const __id = handlers.{identity_type}.of(__claims.value.sub);"
805                );
806                let _ = writeln!(
807                    out,
808                    "              if (__id.tag === \"Ok\") __who = {{ tag: \"{tag}\", identity: __id.value }};"
809                );
810                let _ = writeln!(out, "            }}");
811                let _ = writeln!(out, "          }}");
812                let _ = writeln!(out, "        }}");
813            }
814            SumMemberSeam::Signature(seam) => {
815                let header = seam.header.replace('\\', "\\\\").replace('"', "\\\"");
816                emit_secret_lookup(out, "__secret", &seam.secret, "        ");
817                let _ = writeln!(out, "        if (typeof __secret === \"string\") {{");
818                let ts_expr = match &seam.timestamp_header {
819                    Some(th) => {
820                        let th = th.replace('\\', "\\\\").replace('"', "\\\"");
821                        let _ =
822                            writeln!(out, "          const __ts = request.headers.get(\"{th}\");");
823                        "__ts"
824                    }
825                    None => "null",
826                };
827                let tol = match seam.tolerance_secs {
828                    Some(n) => n.to_string(),
829                    None => "null".to_string(),
830                };
831                let _ = writeln!(
832                    out,
833                    "          const __sig_ok = await verifySignatureHmacSha256(__raw, __secret, request.headers.get(\"{header}\"), {ts_expr}, {tol});"
834                );
835                let _ = writeln!(out, "          if (__sig_ok) __who = {{ tag: \"{tag}\" }};");
836                let _ = writeln!(out, "        }}");
837            }
838        }
839        let _ = writeln!(out, "      }}");
840    }
841    let _ = writeln!(
842        out,
843        "      if (__who === undefined) return HttpResult.Unauthorized;"
844    );
845
846    // Parse the body param from the raw bytes already read (fail-closed → 400).
847    let mut call_args: Vec<String> = path_params.iter().map(|n| n.to_string()).collect();
848    if let Some(body_param) = h.params.iter().find(|p| p.name.name == "body") {
849        let _ = writeln!(out, "      let __body_json: JsonValue;");
850        let _ = writeln!(out, "      try {{");
851        let _ = writeln!(out, "        __body_json = JSON.parse(__raw) as JsonValue;");
852        let _ = writeln!(out, "      }} catch {{");
853        let _ = writeln!(
854            out,
855            "        return HttpResult.BadRequest(\"Invalid request body\");"
856        );
857        let _ = writeln!(out, "      }}");
858        let dser = super::workers_entry::deserialise_call(&body_param.type_ref, "__body_json", "$");
859        let _ = writeln!(out, "      const __r_body = {dser};");
860        let _ = writeln!(
861            out,
862            "      if (__r_body.tag === \"Err\") return HttpResult.BadRequest(\"Invalid request body\");"
863        );
864        let _ = writeln!(out, "      const body = __r_body.value;");
865        call_args.push("body".to_string());
866    }
867    let _ = writeln!(
868        out,
869        "      return handlers.{sname}.{method_key}({}{}{{ ...deps, who: __who }});",
870        call_args.join(", "),
871        if call_args.is_empty() { "" } else { ", " },
872    );
873    let _ = writeln!(out, "    }},");
874}