Skip to main content

bynk_emit/emitter/
secrets.rs

1//! `bynk-secrets.json` generation per Worker (v0.172, ADR 0195).
2//!
3//! The **declared** secret names a context's handlers will read from `env` at
4//! runtime — an `actor`'s `auth = Bearer(secret = "…")` / `Signature(secret =
5//! "…")`, including the members of a multi-actor sum. `deploy` reads this file
6//! to know which secrets it must see set before it pushes.
7//!
8//! The file carries **two kinds of knowledge, which are not equally strong**
9//! (ADR 0196):
10//!
11//! - `declared` — an `actor`'s auth secret. A literal fixed at parse time,
12//!   required at compile time, and **fail-closed**: unset, the Worker answers
13//!   401 to every request. `deploy` refuses to ship without a value.
14//! - `read` — a literal `bynk.Secrets` name (`Secrets.get("X")`). `get` returns
15//!   `Option`, so absence is a legitimate handled outcome — these are
16//!   **advisory**, and `deploy` warns rather than failing.
17//!
18//! And `read_complete`, which is the honesty. `Secrets.get` takes an ordinary
19//! `String` expression, so a computed name is invisible to any pass: where one
20//! is seen, the context warns (`bynk.secrets.computed_name`) and this flag goes
21//! false. So does composing an adapter provider `given` `bynk`'s `Secrets`
22//! (#1976): its binding is TypeScript, and reads names no pass can see.
23//! `declared` is a **floor, not a census** (ADR 0195 D2); `read` is a census
24//! only while `read_complete` holds, and says so when it does not.
25//!
26//! Why a file rather than an API: the driver has two compile paths, and under a
27//! `bynkc` override the compiler is a child process handing back an exit status
28//! — there is no in-memory model to consult. A name the compiler knows must
29//! reach the driver in the build output, or not at all (ADR 0195 D5).
30//!
31//! P5.5 (`design/tracks/semantics-in-the-checker.md` §6, §9): the checking
32//! half — `SecretReads`, the `Secrets.get` AST walk, and the
33//! `bynk.secrets.computed_name` diagnostic itself — moved to
34//! `bynk_check::secrets`, a real gap this track's settling pass had not
35//! scoped (see that module's own doc). What stays here is emission-only:
36//! rendering the manifest `bynk deploy` reads, which is not this crate's
37//! business to ask `bynk-check` to do.
38
39use std::collections::BTreeSet;
40
41use bynk_project::json_string;
42
43use bynk_check::actors::SumMemberSeam;
44use bynk_check::secrets::SecretReads;
45
46use crate::project::UnitTable;
47
48/// The file the driver reads, beside each Worker's `wrangler.toml`.
49pub const SECRETS_MANIFEST: &str = "bynk-secrets.json";
50
51/// The manifest schema version. Bumped only by a breaking shape change; the
52/// driver refuses a version it does not know rather than guessing, as the
53/// deploy ledger does.
54///
55/// **2** (v0.173, ADR 0196) added `read` and `read_complete`. The bump is
56/// deliberate rather than a default-on-absence read: a v1 manifest carries no
57/// evidence either way about computed names, and defaulting `read_complete` to
58/// `true` for it would be the manifest's one claim that could be silently wrong.
59const MANIFEST_VERSION: u32 = 2;
60
61/// Every secret name this context's handlers will read from `env`.
62///
63/// Enumerated over exactly the handlers the entry emitter lowers seams for
64/// (`table.services`, which is where a `from websocket` service lives too), and
65/// resolved with exactly the same `bynk_check::actors` functions — so the
66/// manifest cannot describe a Worker other than the one emitted beside it. An
67/// actor that is declared but named by no handler's `by` clause resolves no
68/// seam and contributes nothing: the Worker never reads it.
69///
70/// `Oidc` names no secret — its trust root is the provider's published JWKS, not
71/// a shared value — and a sum's `None` member (a catch-all such as `Visitor`)
72/// verifies nothing. Both are skipped rather than defaulted: inventing a name
73/// for them would ask the user to set a secret that nothing reads.
74///
75/// I3 (#1971): a `Foreign` actor's secret is **not** declared here, on purpose.
76/// A declared secret is the user's to supply, and `deploy` prompts for one it
77/// lacks; a foreign caller's is minted by `deploy` and handed to the foreign
78/// Worker's operator ([`foreign_callers`]). None of the resolvers above match a
79/// `Foreign` actor, and a test pins that.
80pub(crate) fn declared_secrets(table: &UnitTable) -> BTreeSet<String> {
81    let mut names = BTreeSet::new();
82    for handler in table.services.values().flat_map(|s| s.handlers.iter()) {
83        if let Some(seam) = bynk_check::actors::bearer_seam_for(handler, &table.actors) {
84            names.insert(seam.secret);
85        }
86        if let Some(seam) = bynk_check::actors::signature_seam_for(handler, &table.actors) {
87            names.insert(seam.secret);
88        }
89        for member in bynk_check::actors::sum_members_for(handler, &table.actors)
90            .into_iter()
91            .flatten()
92        {
93            match member.seam {
94                SumMemberSeam::Bearer { secret, .. } => {
95                    names.insert(secret);
96                }
97                SumMemberSeam::Signature(seam) => {
98                    names.insert(seam.secret);
99                }
100                SumMemberSeam::None => {}
101            }
102        }
103    }
104    names
105}
106
107/// I3 (#1971): the foreign callers this context's `on call` handlers admit, by
108/// actor name, each with the secret its calls verify under and the foreign
109/// Worker's name when declared. `deploy` mints each secret, sets it on this
110/// Worker, and prints the command that hands it to the foreign Worker.
111pub(crate) fn foreign_callers(table: &UnitTable) -> Vec<bynk_check::actors::ForeignCaller> {
112    let mut callers: Vec<bynk_check::actors::ForeignCaller> = Vec::new();
113    for handler in table.services.values().flat_map(|s| s.handlers.iter()) {
114        for f in bynk_check::actors::call_admission_for(handler, &table.actors)
115            .map(|a| a.foreign)
116            .unwrap_or_default()
117        {
118            if !callers.iter().any(|g| g.actor == f.actor) {
119                callers.push(f);
120            }
121        }
122    }
123    callers.sort_by(|a, b| a.actor.cmp(&b.actor));
124    callers
125}
126
127/// The reads half of what a Worker knows about its own `bynk.Secrets` use —
128/// `declared_secrets`'s counterpart, read from the AST rather than derived
129/// from actor bindings. The walk itself, its `bynk.secrets.computed_name`
130/// diagnostic, and the `reads_secrets_of_bynk` capability-resolution guard
131/// live in `bynk_check::secrets` (P5.5); the Worker-wide closure — handlers
132/// plus every provider the composition root builds, and the adapter-binding
133/// case no pass can see (#1976) — is [`bynk_check::secrets::worker_secret_reads`].
134/// `run_checks` raises the warnings, via
135/// `bynk_check::project_model::phase_secrets_computed_name`, per file.
136pub(crate) fn secret_reads(
137    context: &str,
138    unit_tables: &std::collections::HashMap<String, UnitTable>,
139    unit_consumes: &std::collections::HashMap<String, Vec<String>>,
140    unit_consumes_aliases: &std::collections::HashMap<
141        String,
142        std::collections::HashMap<String, String>,
143    >,
144    unit_flattened: &std::collections::HashMap<String, std::collections::HashMap<String, String>>,
145) -> SecretReads {
146    bynk_check::secrets::worker_secret_reads(
147        context,
148        unit_tables,
149        unit_consumes,
150        unit_consumes_aliases,
151        unit_flattened,
152    )
153}
154
155/// Render the manifest for a context, or `None` when there is nothing to say.
156///
157/// Emitted when **anything** is known ([DECISION E]): a declared secret, a read
158/// name, or the fact that a name is computed. Slice 3's rule — emit only for a
159/// non-empty `declared` — would have stayed silent for a context that reads
160/// secrets but declares none, which is exactly the context this file now exists
161/// to describe.
162pub(crate) fn emit_secrets_manifest(table: &UnitTable, reads: &SecretReads) -> Option<String> {
163    render(&declared_secrets(table), reads, &foreign_callers(table))
164}
165
166/// Render a resolved name set. Split from the derivation so the file's shape is
167/// tested without building a project model.
168///
169/// Absent rather than empty: a project with no declared secret must not grow a
170/// file into every worker directory for a feature it does not use — and "no
171/// file" is the same answer as "an empty list" to a driver that must tolerate a
172/// build tree from a compiler predating this file anyway.
173fn render(
174    declared: &BTreeSet<String>,
175    reads: &SecretReads,
176    foreign: &[bynk_check::actors::ForeignCaller],
177) -> Option<String> {
178    // `complete` is the third thing worth saying: a context that reads one
179    // computed name and nothing else knows something — that it does not know —
180    // and the file has to carry it or the driver cannot.
181    if declared.is_empty() && reads.names.is_empty() && reads.complete && foreign.is_empty() {
182        return None;
183    }
184    // I3 (#1971): `foreign` only when there is one. It is additive within
185    // version 2 rather than a version 3: a manifest without it says, truly,
186    // that the context admits no foreign caller, so an older file reads the
187    // same as before and every existing manifest stays byte-identical.
188    let foreign = if foreign.is_empty() {
189        String::new()
190    } else {
191        let rendered: Vec<String> = foreign
192            .iter()
193            .map(|f| {
194                let worker = f
195                    .worker
196                    .as_ref()
197                    .map(|w| format!(", \"worker\": {}", json_string(w)))
198                    .unwrap_or_default();
199                format!(
200                    "    {{ \"actor\": {}, \"secret\": {}{worker} }}",
201                    json_string(&f.actor),
202                    json_string(&f.secret)
203                )
204            })
205            .collect();
206        format!(",\n  \"foreign\": [\n{}\n  ]", rendered.join(",\n"))
207    };
208    // Hand-rendered rather than via serde: this crate does not depend on
209    // serde_json, and the shape is a handful of fields.
210    Some(format!(
211        "{{\n  \"version\": {MANIFEST_VERSION},\n  \"declared\": {},\n  \"read\": {},\n  \"read_complete\": {}{foreign}\n}}\n",
212        json_array(declared),
213        json_array(&reads.names),
214        reads.complete,
215    ))
216}
217
218/// A JSON array of names, one per line, or `[]`.
219fn json_array(names: &BTreeSet<String>) -> String {
220    if names.is_empty() {
221        return "[]".to_string();
222    }
223    let rendered: Vec<String> = names
224        .iter()
225        .map(|n| format!("    {}", json_string(n)))
226        .collect();
227    format!("[\n{}\n  ]", rendered.join(",\n"))
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    fn set(names: &[&str]) -> BTreeSet<String> {
235        names.iter().map(|n| n.to_string()).collect()
236    }
237
238    #[test]
239    fn a_name_is_escaped_as_a_json_string() {
240        assert_eq!(json_string("AUTH_JWT_SECRET"), "\"AUTH_JWT_SECRET\"");
241        // A secret name is a Bynk string literal, so it is arbitrary text; the
242        // manifest must stay parseable rather than usually-parseable.
243        assert_eq!(json_string("a\"b"), "\"a\\\"b\"");
244        assert_eq!(json_string("a\\b"), "\"a\\\\b\"");
245        assert_eq!(json_string("a\nb"), "\"a\\nb\"");
246        assert_eq!(json_string("a\u{1}b"), "\"a\\u0001b\"");
247    }
248
249    fn reads(names: &[&str], complete: bool) -> SecretReads {
250        SecretReads {
251            names: set(names),
252            complete,
253        }
254    }
255
256    /// [DECISION E]: the file appears when **anything** is known, and only then.
257    #[test]
258    fn a_manifest_appears_when_anything_is_known_and_not_otherwise() {
259        // Nothing at all — no file. A project with no secrets must not grow one
260        // into every worker directory.
261        assert_eq!(render(&set(&[]), &reads(&[], true), &[]), None);
262
263        // A read with no declared secret is exactly the context slice 3's rule
264        // would have stayed silent about — and exactly the one worth describing.
265        assert!(render(&set(&[]), &reads(&["API_KEY"], true), &[]).is_some());
266
267        // Knowing that you *don't* know is knowledge too: a context whose only
268        // secret is computed still emits, or the driver cannot say its list is
269        // incomplete.
270        assert!(render(&set(&[]), &reads(&[], false), &[]).is_some());
271    }
272
273    /// The committed shape, byte for byte — it is a file a reviewer reads in a
274    /// fixture diff and a driver parses. Asserted here rather than round-tripped
275    /// through a parser because this crate deliberately carries three
276    /// dependencies and `serde_json` is not among them; that the bytes *parse*
277    /// is asserted driver-side, where the reader lives.
278    #[test]
279    fn the_manifest_is_sorted_and_pinned() {
280        // A `BTreeSet` orders the names, so the file is byte-stable for a given
281        // context rather than dependent on handler iteration order.
282        assert_eq!(
283            render(&set(&["B_SECRET", "A_SECRET"]), &reads(&["R"], true), &[])
284                .expect("a non-empty set renders"),
285            "{\n  \"version\": 2,\n  \"declared\": [\n    \"A_SECRET\",\n    \"B_SECRET\"\n  ],\n  \
286             \"read\": [\n    \"R\"\n  ],\n  \"read_complete\": true\n}\n",
287        );
288        // The empty-list and false-flag shapes, which the fixtures also carry.
289        assert_eq!(
290            render(&set(&["ONLY"]), &reads(&[], false), &[]).expect("renders"),
291            "{\n  \"version\": 2,\n  \"declared\": [\n    \"ONLY\"\n  ],\n  \
292             \"read\": [],\n  \"read_complete\": false\n}\n",
293        );
294    }
295
296    /// I3 (#1971): foreign callers, and only foreign callers, make a file, with
297    /// the worker named where it was declared.
298    #[test]
299    fn foreign_callers_are_listed_apart_from_declared_secrets() {
300        let foreign = vec![
301            bynk_check::actors::ForeignCaller {
302                actor: "Ledger".into(),
303                secret: "LEDGER_CALL_KEY".into(),
304                worker: Some("ledger-svc".into()),
305            },
306            bynk_check::actors::ForeignCaller {
307                actor: "Legacy".into(),
308                secret: "LEGACY_CALL_KEY".into(),
309                worker: None,
310            },
311        ];
312        assert_eq!(
313            render(&set(&[]), &reads(&[], true), &foreign).expect("a foreign caller renders"),
314            "{\n  \"version\": 2,\n  \"declared\": [],\n  \"read\": [],\n  \"read_complete\": true,\n  \
315             \"foreign\": [\n    { \"actor\": \"Ledger\", \"secret\": \"LEDGER_CALL_KEY\", \"worker\": \"ledger-svc\" },\n    \
316             { \"actor\": \"Legacy\", \"secret\": \"LEGACY_CALL_KEY\" }\n  ]\n}\n",
317        );
318    }
319}