1use std::collections::HashMap;
15use std::fmt::Write as _;
16use std::sync::Arc;
17
18use bynk_syntax::ast::{BaseType, CommonsItem, SourceUnit, TypeDecl};
19
20use super::{
21 ACCEPTED_WIRE_VERSIONS, CALL_CHECKS, CALL_METHOD, CALL_PATH_PREFIX, CALLER_HEADER,
22 CONTRACT_HEADER, EDGE_FROM_PREFIX, EDGE_TO_PREFIX, HEADERS, MAX_BODY_BYTES, SIGNATURE_HEADER,
23 SIGNATURE_WINDOW_SECONDS, SIGNING_SCHEME, TIMESTAMP_HEADER, WIRE_HEADER, WIRE_VERSION,
24 edge_key_from, edge_key_to, signing_input,
25};
26use crate::contract::{FNV1A64_OFFSET, FNV1A64_PRIME, contract_hash, service_normal_form};
27use crate::resolver::cross_context_service_for;
28use crate::wire::{BaseGuard, JsonKind, WIRE_DISCRIMINANT, field_base_guards, json_kind_of};
29
30pub const EXAMPLE_SOURCE: &str = r#"context wire.sample
32
33type Qty = Int where NonNegative
34
35type Shape =
36 | Circle(radius: Float)
37 | Dot
38
39type Line = {
40 sku: String,
41 qty: Int where Positive,
42 wait: Duration,
43 delay: Option[Duration],
44 at: Instant,
45 blob: Bytes,
46 note: Option[String],
47 tags: List[String],
48 stock: Map[String, Int],
49 rush: Bool,
50}
51
52service quote {
53 on call(line: Line, size: Qty) -> Effect[Result[Shape, String]] {
54 Ok(Dot)
55 }
56}
57
58type Tagged[T] = {
59 n: Int where Positive,
60 v: T,
61}
62
63service who {
64 on call(t: Tagged[String]) -> Effect[Result[String, String]] by c: Caller {
65 Ok(c.identity)
66 }
67}
68
69service ping {
70 on call() -> Effect[Result[(), String]] {
71 Ok(())
72 }
73}
74
75actor Legacy { auth = Foreign(secret = "LEGACY_CALL_KEY") }
76
77service legacy {
78 on call(n: Int) -> Effect[Result[Int, String]] by Legacy {
79 Ok(n)
80 }
81}
82"#;
83
84pub enum Outcome {
86 Accepted { encodes_as: &'static str },
88 Rejected {
90 kind: &'static str,
91 path: &'static str,
92 },
93}
94
95pub struct Example {
97 pub ty: &'static str,
99 pub codec: &'static str,
101 pub json: &'static str,
102 pub outcome: Outcome,
103 pub note: &'static str,
104}
105
106const LINE: &str = r#"{"sku":"A-1","qty":2,"wait":1500,"delay":{"kind":"Some","value":250},"at":1767225600000,"blob":"aGk=","note":{"kind":"Some","value":"gift"},"tags":["new"],"stock":[["lon",3]],"rush":false}"#;
107
108pub const EXAMPLES: &[Example] = &[
111 Example {
112 ty: "Line",
113 codec: "Line",
114 json: LINE,
115 outcome: Outcome::Accepted { encodes_as: LINE },
116 note: "Every field present. A record is an object keyed by field name.",
117 },
118 Example {
119 ty: "Line",
120 codec: "Line",
121 json: r#"{"sku":"A-1","qty":2,"wait":1500,"at":1767225600000,"blob":"","tags":[],"stock":[],"rush":true,"extra":1}"#,
122 outcome: Outcome::Accepted {
123 encodes_as: r#"{"sku":"A-1","qty":2,"wait":1500,"delay":{"kind":"None"},"at":1767225600000,"blob":"","note":{"kind":"None"},"tags":[],"stock":[],"rush":true}"#,
124 },
125 note: "An absent `Option` field is `None`. An unknown key is ignored.",
126 },
127 Example {
128 ty: "Line",
129 codec: "Line",
130 json: r#"{"sku":"A-1","qty":2,"wait":1500,"at":1767225600000,"blob":"","note":"gift","tags":[],"stock":[],"rush":true}"#,
131 outcome: Outcome::Accepted {
132 encodes_as: r#"{"sku":"A-1","qty":2,"wait":1500,"delay":{"kind":"None"},"at":1767225600000,"blob":"","note":{"kind":"Some","value":"gift"},"tags":[],"stock":[],"rush":true}"#,
133 },
134 note: "A bare value where an `Option` is expected is `Some`.",
135 },
136 Example {
137 ty: "Line",
138 codec: "Line",
139 json: r#"{"sku":"A-1","qty":0,"wait":1500,"at":1767225600000,"blob":"","tags":[],"stock":[],"rush":true}"#,
140 outcome: Outcome::Rejected {
141 kind: "RefinementViolation",
142 path: "$.qty",
143 },
144 note: "An inline field refinement (`qty: Int where Positive`) is checked.",
145 },
146 Example {
147 ty: "Line",
148 codec: "Line",
149 json: r#"{"sku":"A-1","qty":1.5,"wait":1500,"at":1767225600000,"blob":"","tags":[],"stock":[],"rush":true}"#,
150 outcome: Outcome::Rejected {
151 kind: "StructuralMismatch",
152 path: "$.qty",
153 },
154 note: "An `Int` is a whole number.",
155 },
156 Example {
157 ty: "Line",
158 codec: "Line",
159 json: r#"{"sku":"A-1","qty":2,"wait":1.5,"at":1767225600000,"blob":"","tags":[],"stock":[],"rush":true}"#,
160 outcome: Outcome::Rejected {
161 kind: "StructuralMismatch",
162 path: "$.wait",
163 },
164 note: "A `Duration` is whole milliseconds.",
165 },
166 Example {
167 ty: "Line",
168 codec: "Line",
169 json: r#"{"sku":"A-1","qty":2,"wait":1500,"delay":{"kind":"Some","value":0.5},"at":1767225600000,"blob":"","tags":[],"stock":[],"rush":true}"#,
170 outcome: Outcome::Rejected {
171 kind: "StructuralMismatch",
172 path: "$.delay.value",
173 },
174 note: "So is a `Duration` inside an `Option`, a `List` or a variant.",
175 },
176 Example {
177 ty: "Line",
178 codec: "Line",
179 json: r#"{"sku":"A-1","qty":2,"wait":1500,"at":1767225600000,"blob":"%%","tags":[],"stock":[],"rush":true}"#,
180 outcome: Outcome::Rejected {
181 kind: "StructuralMismatch",
182 path: "$.blob",
183 },
184 note: "`Bytes` is padded base64.",
185 },
186 Example {
187 ty: "Line",
188 codec: "Line",
189 json: r#"{"sku":"A-1","qty":2,"wait":1500,"at":1767225600000,"blob":"","tags":[],"stock":{"lon":3},"rush":true}"#,
190 outcome: Outcome::Rejected {
191 kind: "StructuralMismatch",
192 path: "$.stock",
193 },
194 note: "A `Map` is an array of pairs, never an object.",
195 },
196 Example {
197 ty: "Qty",
198 codec: "Qty",
199 json: "-1",
200 outcome: Outcome::Rejected {
201 kind: "RefinementViolation",
202 path: "$",
203 },
204 note: "A named refined type is its base value, checked against its predicates.",
205 },
206 Example {
207 ty: "Tagged[String]",
208 codec: "Tagged_String",
209 json: r#"{"n":0,"v":"x"}"#,
210 outcome: Outcome::Rejected {
211 kind: "RefinementViolation",
212 path: "$.n",
213 },
214 note: "A generic record's inline refinements are checked in every instantiation.",
215 },
216 Example {
217 ty: "Shape",
218 codec: "Shape",
219 json: r#"{"kind":"Circle","radius":0.5}"#,
220 outcome: Outcome::Accepted {
221 encodes_as: r#"{"kind":"Circle","radius":0.5}"#,
222 },
223 note: "A variant's payload fields sit beside `kind`.",
224 },
225 Example {
226 ty: "Shape",
227 codec: "Shape",
228 json: r#"{"kind":"Circle","radius":1e999}"#,
229 outcome: Outcome::Rejected {
230 kind: "StructuralMismatch",
231 path: "$.radius",
232 },
233 note: "A `Float` is finite; `1e999` parses as infinity.",
234 },
235 Example {
236 ty: "Shape",
237 codec: "Shape",
238 json: r#"{"kind":"Square"}"#,
239 outcome: Outcome::Rejected {
240 kind: "StructuralMismatch",
241 path: "$",
242 },
243 note: "An unknown variant is refused.",
244 },
245 Example {
246 ty: "Result[Shape, String]",
247 codec: "Result_Shape_String",
248 json: r#"{"kind":"Ok","value":{"kind":"Dot"}}"#,
249 outcome: Outcome::Accepted {
250 encodes_as: r#"{"kind":"Ok","value":{"kind":"Dot"}}"#,
251 },
252 note: "",
253 },
254 Example {
255 ty: "Result[Shape, String]",
256 codec: "Result_Shape_String",
257 json: r#"{"kind":"Err","error":"sold out"}"#,
258 outcome: Outcome::Accepted {
259 encodes_as: r#"{"kind":"Err","error":"sold out"}"#,
260 },
261 note: "A domain error is a `200` carrying `Err`.",
262 },
263 Example {
264 ty: "Result[(), String]",
265 codec: "Result_Unit_String",
266 json: r#"{"kind":"Ok","value":null}"#,
267 outcome: Outcome::Accepted {
268 encodes_as: r#"{"kind":"Ok","value":null}"#,
269 },
270 note: "`()` is `null`.",
271 },
272];
273
274pub fn example_contracts() -> Vec<(String, String, String)> {
277 let tokens = bynk_syntax::lexer::tokenize(EXAMPLE_SOURCE).expect("the example source lexes");
278 let unit = match bynk_syntax::parser::parse_unit(&tokens, EXAMPLE_SOURCE) {
279 Ok(SourceUnit::Context(c)) => c,
280 other => panic!("the example source is a context: {other:?}"),
281 };
282 let types: HashMap<String, Arc<TypeDecl>> = unit
283 .items
284 .iter()
285 .filter_map(|i| match i {
286 CommonsItem::Type(t) => Some((t.name.name.clone(), Arc::new(t.clone()))),
287 _ => None,
288 })
289 .collect();
290 unit.items
291 .iter()
292 .filter_map(|i| match i {
293 CommonsItem::Service(s) => cross_context_service_for(&s.name.name, s),
294 _ => None,
295 })
296 .map(|svc| {
297 let form = service_normal_form(&svc, &types);
298 let hash = contract_hash(&form);
299 (svc.name, form, hash)
300 })
301 .collect()
302}
303
304fn json_kind_name(k: JsonKind) -> &'static str {
305 match k {
306 JsonKind::Number => "number",
307 JsonKind::String => "string",
308 JsonKind::Boolean => "boolean",
309 JsonKind::Object => "object",
310 JsonKind::Array => "array",
311 JsonKind::Null => "null",
312 }
313}
314
315fn base_type_rows(out: &mut String) {
318 const BASES: [(BaseType, &str); 7] = [
319 (BaseType::Int, "an integer"),
320 (BaseType::Float, "a number"),
321 (BaseType::Bool, "`true` or `false`"),
322 (BaseType::String, "a string"),
323 (
324 BaseType::Bytes,
325 "standard base64 with padding (RFC 4648 §4)",
326 ),
327 (BaseType::Duration, "whole milliseconds"),
328 (BaseType::Instant, "milliseconds since the Unix epoch"),
329 ];
330 for (base, value) in BASES {
331 let kind = json_kind_of(base);
332 let rule = if base == BaseType::Bytes {
333 "valid base64; anything else is `StructuralMismatch` (`expected: \"base64 string\"`)"
334 .to_string()
335 } else {
336 let mut rule = format!("JSON {}", json_kind_name(kind));
337 for guard in field_base_guards(base) {
338 rule.push_str(match guard {
339 BaseGuard::Integral => {
340 ", and `Number.isSafeInteger` (whole, within ±(2^53 − 1)); otherwise `StructuralMismatch` (`expected: \"safe integer\"`)"
341 }
342 BaseGuard::Finite => {
343 ", and finite; otherwise `StructuralMismatch` (`expected: \"finite number\"`)"
344 }
345 });
346 }
347 rule
348 };
349 let encode = match base {
350 BaseType::Int | BaseType::Duration | BaseType::Instant => {
351 "an unsafe value throws in the sender (a `500`), never a lossy number"
352 }
353 BaseType::Float => "a non-finite value throws in the sender",
354 _ => "as is",
355 };
356 let _ = writeln!(
357 out,
358 "| `{}` | {} ({value}) | {rule} | {encode} |",
359 base.name(),
360 json_kind_name(kind),
361 );
362 }
363}
364
365fn status_rows(out: &mut String) {
366 for (i, check) in CALL_CHECKS.iter().enumerate() {
367 let response = check.response();
368 let _ = writeln!(
369 out,
370 "| {} | {} | `{}` | {response} |",
371 i + 1,
372 check.condition(),
373 check.status(),
374 );
375 }
376}
377
378pub fn render_reference() -> String {
380 let mut out = String::new();
381 let accepted = ACCEPTED_WIRE_VERSIONS
382 .iter()
383 .map(|v| format!("`{v}`"))
384 .collect::<Vec<_>>()
385 .join(", ");
386
387 let from_example = edge_key_from("shop.orders").unwrap_or_default();
388 let to_example = edge_key_to("shop.payment").unwrap_or_default();
389 let input_example = signing_input(
390 "shop.orders",
391 "shop.payment",
392 CALL_METHOD,
393 &format!("{CALL_PATH_PREFIX}authorise"),
394 "0123456789abcdef",
395 "1",
396 "1760000000",
397 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
398 );
399 let _ = write!(
400 out,
401 r#"# Wire format (version {WIRE_VERSION})
402
403<!-- GENERATED FILE — do not edit by hand.
404 Source: bynk-check/src/wire_format.rs and wire_format/reference.rs, via bynkc/tests/wire_reference.rs.
405 Regenerate with: BYNK_BLESS=1 cargo test -p bynkc --test it wire_reference:: -->
406
407How one Bynk context calls another context's `on call` service over a
408Cloudflare Service Binding: the request, the checks the callee makes, the
409responses, the JSON encoding of every type that can cross, and the contract
410hash. Code that is not Bynk can call a Bynk service by speaking this format.
411
412This page is generated from the compiler. The header names, the check order
413and the status codes are the constants the emitter builds the call dispatch
414from; the encoding table is read from the compiler's wire model; and every
415example below is run through the codecs the compiler generates for the
416example context, by `bynkc/tests/wire_reference.rs`.
417
418## Version and stability
419
420This is wire format version **{WIRE_VERSION}**. It is versioned on its own, not
421with the compiler: a compiler release changes it only by adding a new wire
422version.
423
424- **Within a version, changes are additive only.** A new status code, a new
425 error `kind` or a new optional header may appear. A caller must treat an
426 error `kind` it does not know as an error.
427- **Anything else is a new version:** changing an encoding, a check, a status,
428 an existing error body, or the contract hash's normal form or function.
429- **The caller states its version** in `{WIRE_HEADER}`; an absent header means
430 `1`. **The callee accepts a range** (this compiler's callees accept
431 {accepted}) and refuses any other version with the versions it accepts, so a
432 version is retired after an overlap window, not on a flag day.
433- **The signature is part of the version:** its headers, its signing input
434 and its window. The keys are not; they belong to a deployment.
435
436What the format does **not** cover: the TypeScript the compiler emits, the
437in-memory representation of values, and the Worker's other entry points
438(`from http` routes, events, queues). Those follow the compiler's own version.
439
440## Request
441
442```text
443{CALL_METHOD} {CALL_PATH_PREFIX}<service>
444```
445
446`<service>` is the service's name as declared (`service quote` is
447`{CALL_PATH_PREFIX}quote`). The host is ignored: a Bynk caller sends
448`http://internal{CALL_PATH_PREFIX}<service>` over the Service Binding.
449
450| Header | Required | Meaning |
451|---|---|---|
452"#
453 );
454 for h in HEADERS {
455 let _ = writeln!(out, "| `{}` | {} | {} |", h.name, h.required, h.meaning);
456 }
457 let _ = write!(
458 out,
459 r#"
460**Body.** JSON, encoding the handler's parameters:
461
462- **no parameters:** any JSON value; Bynk callers send `{{}}`;
463- **one parameter:** the parameter's value itself, not wrapped;
464- **two or more:** an object keyed by parameter name. Unknown keys are ignored.
465
466So `on call(line: Line, size: Qty)` takes `{{"line": {{…}}, "size": 3}}`, and
467`on call(size: Qty)` takes `3`. Renaming or reordering parameters is a contract
468change (see [Contract hash](#contract-hash)).
469
470## Checks and responses
471
472The callee makes these checks in this order. The first that fails answers the
473request.
474
475| # | Check | Status on failure | Body on failure |
476|---|---|---|---|
477"#
478 );
479 status_rows(&mut out);
480 let _ = write!(
481 out,
482 r#"
483If every check passes, the handler runs:
484
485- **`200`**, `content-type: application/json`, with the handler's return value
486 encoded as below. A domain error is a `200` too: `Result[T, E]`'s `Err` is a
487 value, not a status.
488- **`500`**, text `Internal Server Error`, if the handler faults (including
489 encoding a value the format cannot carry, such as an unsafe `Int`). The
490 callee logs the fault; the body says nothing more. The response carries
491 `X-Bynk-Fault: <context>`, the callee's qualified name, so the caller can
492 say which Worker faulted. Only a `/_bynk/call/` fault is marked.
493
494A status not listed here (a platform error between the Workers, for example)
495is a transport failure. A Bynk caller reports a `409` `ContractMismatch` and a
496marked `500` `CalleeFaulted` by name, and every other non-`2xx` as
497`Transport`, with the status.
498
499### Error bodies
500
501Every JSON error body is an object with a `kind`:
502
503| `kind` | Status | Fields |
504|---|---|---|
505| `UnsupportedWireVersion` | `400` | `accepted`: the wire versions the callee accepts |
506| `ContractMismatch` | `409` | `service`, `expected` (the callee's hash), `actual` (the header as received) |
507| `MalformedJson` | `400` | `details` |
508| `StructuralMismatch` | `400` | `path`, `expected`, `actual`: the value had the wrong shape |
509| `RefinementViolation` | `400` | `path`, `violation`: `{{"field", "message", "value"}}`, a value of the right shape broke a predicate |
510
511`path` is a JSONPath-like location in the body: `$` is the whole body (one
512parameter), `$.<param>` a parameter (several), then `.<field>` or `[<index>]`
513inward. An unknown variant fails at the sum's own `path`, with
514`expected: "sum variant kind"` and the `kind` that was sent as `actual`.
515
516## Encoding
517
518Each type has one JSON encoding, the same in both directions. A value is
519decoded by checking its shape, then any predicates.
520
521### Base types
522
523| Type | JSON | Decode accepts | Encode |
524|---|---|---|---|
525"#
526 );
527 base_type_rows(&mut out);
528 let _ = write!(
529 out,
530 r#"| `()` | `null` | anything; the value is not inspected | `null` |
531
532`Int`, `Duration` and `Instant` are limited to JavaScript's safe-integer range
533on the wire, so every JSON parser reads them exactly.
534
535### Composite types
536
537| Type | JSON |
538|---|---|
539| record | an object keyed by field name. A missing field decodes as its default when it has one, as `None` when it is an `Option`, and otherwise fails. Unknown keys are ignored. |
540| sum (`enum` included) | an object with `"{WIRE_DISCRIMINANT}"`: the variant's name, beside the variant's payload fields: `{{"{WIRE_DISCRIMINANT}":"Circle","radius":0.5}}`, `{{"{WIRE_DISCRIMINANT}":"Dot"}}` |
541| `Result[T, E]` | `{{"{WIRE_DISCRIMINANT}":"Ok","value":<T>}}` or `{{"{WIRE_DISCRIMINANT}":"Err","error":<E>}}` |
542| `Option[T]` | `{{"{WIRE_DISCRIMINANT}":"Some","value":<T>}}` or `{{"{WIRE_DISCRIMINANT}":"None"}}`. A sender may also send `null` or leave the field out for `None`, or send the bare value for `Some`; Bynk always encodes the tagged form |
543| `List[T]` | an array |
544| `Map[K, V]` | an array of `[key, value]` pairs, in insertion order |
545| refined type (`type Qty = Int where NonNegative`) | its base type's encoding. The decoder checks the predicates, refusing with `RefinementViolation` |
546| opaque type | its base type's encoding. A caller cannot see the predicate, so only the owning context checks it |
547| inline field refinement (`qty: Int where Positive`) | the field's encoding. The decoder checks the predicates, like a refined type's |
548| generic instantiation (`Page[Line]`) | the declaration's encoding with the arguments substituted |
549
550The in-memory discriminant of a Bynk sum is `tag`; on the wire it is
551`"{WIRE_DISCRIMINANT}"`. The encoding never depends on the in-memory layout.
552
553### Examples
554
555Each row is decoded by the codec the compiler generates for `wire.sample`
556below, then encoded again. Rejections show the error's `kind` and `path`.
557
558```bynk
559"#
560 );
561 out.push_str(EXAMPLE_SOURCE.trim_end());
562 out.push_str("\n```\n\n| Type | JSON sent | Outcome | Note |\n|---|---|---|---|\n");
563 for ex in EXAMPLES {
564 let outcome = match &ex.outcome {
565 Outcome::Accepted { encodes_as } if *encodes_as == ex.json => {
566 "accepted; encodes back the same".to_string()
567 }
568 Outcome::Accepted { encodes_as } => {
569 format!("accepted; encodes back as `{encodes_as}`")
570 }
571 Outcome::Rejected { kind, path } => format!("`{kind}` at `{path}`"),
572 };
573 let _ = writeln!(
574 out,
575 "| `{}` | `{}` | {outcome} | {} |",
576 ex.ty, ex.json, ex.note
577 );
578 }
579
580 let contracts = example_contracts();
581 let _ = write!(
582 out,
583 r#"
584## Contract hash
585
586`X-Bynk-Contract` carries a hash of the service's signature, so a callee can
587refuse a caller that was compiled against a different version of it (`409`).
588It detects skew; it is not a credential and not a security control.
589
590**Normal form.** One line per service:
591
592```text
593<service>(<param>: <type>, …) -> <type>
594```
595
596- Parameters keep their names and order. `Effect[T]` is written as `T`.
597- Base types are their names; `()` is `()`; `List[T]`, `Option[T]`,
598 `Map[K, V]`, `Result[T, E]` keep their arguments in order.
599- A named type is its name followed by its structure, so both a rename and a
600 change of shape change the hash:
601 - a record is `Name{{f: T, …}}`, each field written `f: T` (or
602 `f: T where P, …` with an inline refinement), the fields sorted by that
603 text's UTF-8 bytes and joined with `, `;
604 - a sum is `Name|A|B(T, …)`: each variant written as its name, followed by
605 its payload types in order in parentheses if it has any, the variants
606 sorted by that text's UTF-8 bytes, each preceded by `|`;
607 - a refined type is its name and base run together, then its predicates:
608 `type Qty = Int where NonNegative` is `QtyInt where NonNegative` (with no
609 predicates, `QtyInt ` with a trailing space);
610 - an opaque type is `NameBase opaque`, without its predicates;
611 - a generic instantiation is the declaration's form with the arguments
612 substituted, followed by `[A, …]`;
613 - a type met again inside its own expansion is `@Name`.
614- Predicates are a set: written as below, sorted by their UTF-8 bytes, with
615 duplicates removed, and joined with `, `. They are `Positive`,
616 `NonNegative`, `InRange(a, b)`, `InRangeF(a, b)`, `MinLength(n)`,
617 `MaxLength(n)`, `Length(n)` and `Matches("…")`; `NonEmpty` is written
618 `MinLength(1)`. Bounds are written as decimal numbers in their shortest
619 round-trip form (`01` is `1`). A `Matches` pattern is quoted, with `\`, `"`
620 and control characters backslash-escaped.
621- No spaces other than those shown; no doc comments, defaults, `given` or
622 `by` clauses.
623
624**Hash.** FNV-1a, 64-bit, over the normal form's UTF-8 bytes: start from the
625offset basis `0x{FNV1A64_OFFSET:016x}`; for each byte, XOR it in, then
626multiply by the prime `0x{FNV1A64_PRIME:016x}` modulo 2^64. The result is
627written as 16 lowercase hex digits.
628
629For `wire.sample` above, each service's normal form, byte for byte, and its
630hash:
631"#
632 );
633 for (name, form, hash) in &contracts {
634 let _ = write!(out, "\n`{name}`, hash `{hash}`:\n\n```text\n{form}\n```\n");
635 }
636 let _ = write!(
637 out,
638 r#"
639| Changes the hash | Does not |
640|---|---|
641| the service's name; a parameter's name, order or type; the return type | field order; variant order; predicate order or duplicates |
642| a field added, removed, renamed or retyped; a variant added, removed, renamed or re-shaped | an opaque type's predicates |
643| a refined type's or an inline field refinement's predicates | doc comments; `given`; `by c: Caller` |
644| renaming a named type | field defaults |
645
646The normal form and the hash function are part of the wire version: a client
647built once embeds hashes, so they change only with a new wire version.
648
649## Authentication
650
651Every call is signed. The callee refuses a call whose signature does not verify
652with a bare `401`, before it looks at anything but the method and the body's
653size, so a sender without a key learns nothing: not which services exist, not
654a contract hash.
655
656**Keys.** One key per *edge*, a caller and a callee. The callee holds it as
657`{EDGE_FROM_PREFIX}<CALLER>` and the caller as `{EDGE_TO_PREFIX}<CALLEE>`, where
658`<CALLER>` and `<CALLEE>` are the qualified context names upper-cased with each
659dot written `__` (`shop.orders` → `{from_example}`, `shop.payment` →
660`{to_example}`). A name the key can be named from is dot-separated segments of
661lowercase letters and digits, each starting with a letter, with single
662underscores between runs. `bynk deploy` mints each key and sets it on both
663Workers; `bynk dev` uses a fixed local key.
664
665**Signing.** The caller sends `{CALLER_HEADER}` (its qualified name),
666`{TIMESTAMP_HEADER}` (whole seconds since the Unix epoch) and `{SIGNATURE_HEADER}`:
667HMAC-SHA256, under the edge's key, of the *signing input*, written as 64
668lowercase hex digits. The signing input is these fields, one per line,
669separated by `\n` with no trailing newline:
670
6711. `{SIGNING_SCHEME}`
6722. the caller, as sent in `{CALLER_HEADER}`
6733. the callee's qualified context name
6744. the method
6755. the URL's pathname exactly as sent, with no query string and no
676 percent-normalisation
6776. `{CONTRACT_HEADER}` as sent, empty when absent
6787. `{WIRE_HEADER}` as sent, `1` when absent
6798. `{TIMESTAMP_HEADER}` as sent
6809. the SHA-256 of the body's bytes, 64 lowercase hex digits
681
682For example, `shop.orders` calling `authorise` on `shop.payment` with an empty
683body signs:
684
685```text
686{input_example}
687```
688
689**Verifying.** The callee reads the body under its cap (1 MiB, {MAX_BODY_BYTES}
690bytes, else `413`), picks the key it holds for the edge from `{CALLER_HEADER}`,
691and refuses (`401`) when it holds none, when `{TIMESTAMP_HEADER}` is more than
692{SIGNATURE_WINDOW_SECONDS} seconds from its clock either way, or when the
693signature does not match, compared in constant time. So:
694
695- **A key's holder can claim only its own name.** A caller's name is the
696 handler's `c.identity`, proved by the key, not claimed.
697- **A key binds one callee.** The callee's name is signed, so a request cannot
698 be replayed against another callee, even one that holds the same key.
699- **Every header the callee acts on is signed**, so none can be changed in
700 flight: not the contract, not the wire version.
701- **A captured call can be replayed inside the window.** The timestamp bounds
702 replay; nothing stops it within those {SIGNATURE_WINDOW_SECONDS} seconds. An
703 `on call` handler with side effects that must happen once should make itself
704 idempotent (the `Idempotency` capability).
705- **An undeclared caller cannot call.** Without a key on the callee there is
706 nothing to verify against, whether or not the handler reads its caller.
707
708### Foreign callers
709
710Code that is not Bynk calls a context the same way, once the context declares
711it as a **foreign caller**:
712
713```text
714actor Legacy {{ auth = Foreign(secret = "LEGACY_CALL_KEY") }}
715
716service legacy {{
717 on call(n: Int) -> Effect[Result[Int, String]] by Legacy {{ Ok(n) }}
718}}
719```
720
721The foreign caller sends its actor's name (`Legacy`) as `{CALLER_HEADER}` and
722signs exactly as above, under the declared secret, which the callee holds as
723`LEGACY_CALL_KEY`. `bynk deploy` mints that secret and prints the
724`wrangler secret put` that hands it to the foreign Worker's operator. An
725actor's name starts with a capital letter, so it is never a context's: a
726foreign caller is checked under its own secret and can never pass for a Bynk
727context, nor a context for it.
728
729A verified caller still has to be one the service's handler admits. A Bynk
730context is admitted by `Caller` (the default, with no `by`), a foreign caller
731only by its own actor; `by who: Caller | Legacy` admits both and tells them
732apart. Anyone else gets a bare `403`.
733
734A TypeScript Worker need not speak any of this by hand. For each context with
735a foreign caller the build emits a typed client, `bynk-client.ts`, which
736signs, stamps the contract hash and encodes exactly as this page describes;
737see [TypeScript client](/book/reference/typescript-client/).
738"#
739 );
740 out
741}