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}