hecks 3.9.0 → 3.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/deploy/bluebook/deploy.bluebook +106 -0
  3. data/lib/hecks/deploy/bluebook/deploy.hecksagon +8 -0
  4. data/lib/hecks/deploy/oidc.json +16 -3
  5. data/lib/hecks/hecks/adapters/deploy_toolchain/clearing.rb +51 -0
  6. data/lib/hecks/hecks/adapters/deploy_toolchain.rb +2 -0
  7. data/lib/hecks/hecks/adapters/site_toolchain/role_prober.rb +78 -0
  8. data/lib/hecks/hecks/adapters/site_toolchain.rb +29 -4
  9. data/lib/hecks/hecks/adapters/world_clearing.rb +65 -0
  10. data/lib/hecks/hecks/hecks.hecksagon +8 -0
  11. data/lib/hecks/hecks/hecks.world +2 -1
  12. data/lib/hecks/projections/deploy/fargate/settings/tuning.rb +15 -0
  13. data/lib/hecks/projections/deploy/fargate/settings.rb +3 -1
  14. data/lib/hecks/projections/deploy/fargate/stack/readers.rb +1 -0
  15. data/lib/hecks/projections/deploy/templates/fargate/Makefile.tmpl +7 -4
  16. data/lib/hecks/projections/deploy/templates/lambda/template.yaml.tmpl +1 -1
  17. data/lib/hecks/projections/site/cms_editor/attributes.rb +75 -0
  18. data/lib/hecks/projections/site/cms_editor/checks.rb +64 -0
  19. data/lib/hecks/projections/site/cms_editor/media.rb +85 -0
  20. data/lib/hecks/projections/site/cms_editor/pretty.rb +40 -0
  21. data/lib/hecks/projections/site/cms_editor/schema.rb +84 -0
  22. data/lib/hecks/projections/site/cms_editor/setting.rb +87 -0
  23. data/lib/hecks/projections/site/cms_editor/templates/package.json.tmpl +19 -0
  24. data/lib/hecks/projections/site/cms_editor/templates/src/app.ts.tmpl +176 -0
  25. data/lib/hecks/projections/site/cms_editor/templates/src/auth/membership.ts.tmpl +79 -0
  26. data/lib/hecks/projections/site/cms_editor/templates/src/auth/session.ts.tmpl +36 -0
  27. data/lib/hecks/projections/site/cms_editor/templates/src/auth/sso.ts.tmpl +20 -0
  28. data/lib/hecks/projections/site/cms_editor/templates/src/commands.ts.tmpl +79 -0
  29. data/lib/hecks/projections/site/cms_editor/templates/src/config.ts.tmpl +23 -0
  30. data/lib/hecks/projections/site/cms_editor/templates/src/host.ts.tmpl +26 -0
  31. data/lib/hecks/projections/site/cms_editor/templates/src/media/handler.ts.tmpl +154 -0
  32. data/lib/hecks/projections/site/cms_editor/templates/src/media/multipart.ts.tmpl +53 -0
  33. data/lib/hecks/projections/site/cms_editor/templates/src/media/sniff.ts.tmpl +94 -0
  34. data/lib/hecks/projections/site/cms_editor/templates/src/media/storage.ts.tmpl +50 -0
  35. data/lib/hecks/projections/site/cms_editor/templates/src/schema.ts.tmpl +85 -0
  36. data/lib/hecks/projections/site/cms_editor/templates/src/server.ts.tmpl +65 -0
  37. data/lib/hecks/projections/site/cms_editor/templates/src/ui/body_model.js.tmpl +120 -0
  38. data/lib/hecks/projections/site/cms_editor/templates/src/ui/body_parse.js.tmpl +320 -0
  39. data/lib/hecks/projections/site/cms_editor/templates/src/ui/body_widget.js.tmpl +282 -0
  40. data/lib/hecks/projections/site/cms_editor/templates/src/ui/fields.ts.tmpl +80 -0
  41. data/lib/hecks/projections/site/cms_editor/templates/src/ui/html.ts.tmpl +102 -0
  42. data/lib/hecks/projections/site/cms_editor/templates/src/ui/input.ts.tmpl +75 -0
  43. data/lib/hecks/projections/site/cms_editor/templates/src/ui/media_picker.js.tmpl +109 -0
  44. data/lib/hecks/projections/site/cms_editor/templates/src/ui/outline.ts.tmpl +27 -0
  45. data/lib/hecks/projections/site/cms_editor/templates/src/ui/pages.ts.tmpl +123 -0
  46. data/lib/hecks/projections/site/cms_editor/templates/tsconfig.json.tmpl +16 -0
  47. data/lib/hecks/projections/site/cms_editor.rb +107 -0
  48. data/lib/hecks/projections/site/role_probe.rb +72 -0
  49. data/lib/hecks/site/bluebook/site.bluebook +28 -1
  50. data/lib/hecks/site/bluebook/site.ports.hecksagon +6 -0
  51. data/lib/hecks/tools/site_routes/cli.rb +1 -1
  52. data/lib/hecks/tools/site_routes/editor_files.rb +38 -0
  53. data/lib/hecks/tools/site_routes.rb +14 -7
  54. data/lib/hecks/version.rb +1 -1
  55. data/prebuilt/{verdicts-3230a251570aa03666708d16f92e646feab6a3f8cc2a3540c017d748e41d41a1.json → verdicts-d6d584ad43b5f3cebf88aafbbdbf024c38574acf18e06adffbc555f7786b1c2f.json} +1 -1
  56. data/rust/Cargo.toml +3 -1
  57. data/rust/codegen/src/bridging.rs +1 -1
  58. data/rust/codegen/src/domain_generator.rs +9 -9
  59. data/rust/codegen/src/main.rs +1 -1
  60. data/rust/codegen/src/mutations.rs +46 -1
  61. data/rust/codegen/src/naming.rs +30 -0
  62. data/rust/host/HECKS_RELEASE +1 -1
  63. data/rust/host/src/boot.rs +443 -0
  64. data/rust/host/src/lib.rs +1 -0
  65. data/rust/host/src/main.rs +4 -438
  66. data/rust/host/src/secrets.rs +15 -0
  67. data/rust/host/src/web/commerce.rs +11 -3
  68. data/rust/host/src/web/resend_webhook.rs +2 -1
  69. data/rust/src/kernel/expr.rs +7 -1
  70. data/rust/src/kernel/expression_operators/presence.rs +41 -3
  71. metadata +42 -5
  72. /data/prebuilt/{4e6c3cd1f578a38732ea5454dae52562d03d8e9886216077a5c34cbf589f3463.marshal → c28d6d9db14eea3e17161e9d9c262efee30b256cf97693a7cfd552bce24b2f7f.marshal} +0 -0
  73. /data/prebuilt/{911cdcc56b5a094ed26583a4c76401dba6455b1f434722ed22b850bc1b1bdd28.marshal → cb375cba61ca0dbe8ddb9739c306510fe05e5f4b7a9b6955caff8224c63ea0e0.marshal} +0 -0
@@ -1,441 +1,7 @@
1
- //! Lambda custom-runtime entry point (or a long-lived server under
2
- //! HECKS_SERVE_MODE=1): boots Postgres/wasm state, then dispatches events.
3
-
4
- use rust_host::{approval, auth, extension, ir, journal, lambda_client, log, mint, rate_limit, secrets, server, storage_shape};
5
-
6
- use lambda_runtime::{service_fn, Error, LambdaEvent};
7
- use std::path::PathBuf;
8
- use std::sync::Arc;
9
- use tokio::sync::Mutex;
1
+ //! Lambda custom-runtime entry point (or a long-lived server under HECKS_SERVE_MODE=1): installs no
2
+ //! extensions of its own, so the host runs with its defaults. See `rust_host::boot::run`.
10
3
 
11
4
  #[tokio::main]
12
- async fn main() -> Result<(), Error> {
13
- // HECKS_SERVE_MODE=1 switches the boot into the long-lived server
14
- // loop below instead of the Lambda custom-runtime loop; every step
15
- // from here through the era-minting sequence is shared, unchanged,
16
- // by both modes.
17
- let boot_started = std::time::Instant::now();
18
- let serve_mode = std::env::var("HECKS_SERVE_MODE").as_deref() == Ok("1");
19
-
20
- // HECKS_SESSION_COOKIE names the account cookie; refuse the boot
21
- // here if it can't be written into a Set-Cookie header, rather
22
- // than falling back silently at request time.
23
- auth::resolve_account_cookie(std::env::var("HECKS_SESSION_COOKIE").ok().as_deref())?;
24
-
25
- // DB_SECRET_ARN: the password is fetched from Secrets Manager here,
26
- // at cold start, rather than trusted from a plaintext
27
- // Environment.Variables value, which any principal with
28
- // lambda:GetFunctionConfiguration could read regardless of the
29
- // secret-at-rest protection its name suggests. Falls back to
30
- // DATABASE_URL directly when DB_SECRET_ARN is absent (manual
31
- // debugging over an SSM tunnel).
32
- //
33
- // The fetcher built here is reused below for GOOGLE_OAUTH_SECRET_ID
34
- // and SESSION_SECRET_ARN too.
35
- let secret_fetcher = secrets::AwsSecretFetcher::from_env().await;
36
- let database_url = match std::env::var("DB_SECRET_ARN") {
37
- Ok(secret_arn) => {
38
- let db_host = std::env::var("DB_HOST").map_err(|_| "DB_HOST is required when DB_SECRET_ARN is set")?;
39
- let db_name = std::env::var("DB_NAME").map_err(|_| "DB_NAME is required when DB_SECRET_ARN is set")?;
40
- let secret_json = secret_fetcher
41
- .fetch_secret_string(&secret_arn)
42
- .await
43
- .map_err(|e| format!("fetching DB_SECRET_ARN from Secrets Manager: {e:#}"))?;
44
- // The user and port come from the secret too (a client sharing an instance logs in as
45
- // its own role); the password goes in literal, since parse_database_url below never
46
- // percent-decodes it.
47
- secrets::database_url(&secret_json, &db_host, &db_name)?
48
- }
49
- Err(_) => std::env::var("DATABASE_URL")
50
- .map_err(|_| "either DB_SECRET_ARN (+ DB_HOST/DB_NAME) or DATABASE_URL is required")?,
51
- };
52
-
53
- // GOOGLE_OAUTH_SECRET_ID/SESSION_SECRET_ARN: the same
54
- // Secrets-Manager-at-cold-start approach as DB_SECRET_ARN above,
55
- // applied to auth.rs's GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET and
56
- // web.rs's SESSION_SECRET.
57
- //
58
- // Safety: this runs before any request is dispatched, so nothing
59
- // else reads or writes the environment concurrently with these
60
- // `set_var` calls.
61
- if let Ok(oauth_secret_id) = std::env::var("GOOGLE_OAUTH_SECRET_ID") {
62
- let secret_json = secret_fetcher
63
- .fetch_secret_string(&oauth_secret_id)
64
- .await
65
- .map_err(|e| format!("fetching GOOGLE_OAUTH_SECRET_ID from Secrets Manager: {e:#}"))?;
66
- let client_id = secrets::extract_field(&secret_json, "client_id")?;
67
- let client_secret = secrets::extract_field(&secret_json, "client_secret")?;
68
- unsafe {
69
- std::env::set_var("GOOGLE_CLIENT_ID", client_id);
70
- std::env::set_var("GOOGLE_CLIENT_SECRET", client_secret);
71
- }
72
- }
73
- if let Ok(session_secret_arn) = std::env::var("SESSION_SECRET_ARN") {
74
- let secret_json = secret_fetcher
75
- .fetch_secret_string(&session_secret_arn)
76
- .await
77
- .map_err(|e| format!("fetching SESSION_SECRET_ARN from Secrets Manager: {e:#}"))?;
78
- let session_secret = secrets::extract_field(&secret_json, "session_secret")?;
79
- unsafe {
80
- std::env::set_var("SESSION_SECRET", session_secret);
81
- }
82
- }
83
- for extension in extension::installed() {
84
- extension.boot_secrets(&secret_fetcher).await;
85
- }
86
- let wasm_path = PathBuf::from(
87
- std::env::var("HECKS_WASM_PATH").unwrap_or_else(|_| "banking.wasm".to_string()),
88
- );
89
-
90
- // Must run before the Postgres connection opens -- `SET search_path`
91
- // below has to run before `journal::ensure_schema` or anything else
92
- // touches the connection, or unqualified names resolve against
93
- // Postgres's default search_path instead of this domain's.
94
- // HECKS_SCHEMA is optional.
95
- let domain = std::env::var("HECKS_DOMAIN").map_err(|_| "HECKS_DOMAIN is required")?;
96
- let schema = std::env::var("HECKS_SCHEMA").ok().filter(|s| !s.is_empty());
97
- // Checkout on AWS keeps the business's payment keys in a named Secrets
98
- // Manager secret; refuse before touching the database when none is named.
99
- for extension in extension::installed() {
100
- extension.boot_check(&domain)?;
101
- }
102
-
103
- // RDS refuses a plain NoTls connection by default and needs AWS's
104
- // own RDS CA specifically, not a generic public bundle (see
105
- // Cargo.toml).
106
- let mut roots = rustls::RootCertStore::empty();
107
- for cert in rustls_pemfile::certs(&mut include_bytes!("../rds-ca-bundle.pem").as_slice()) {
108
- roots.add(cert.map_err(|e| format!("parsing rds-ca-bundle.pem: {e}"))?)?;
109
- }
110
- let tls_config = rustls::ClientConfig::builder()
111
- .with_root_certificates(roots)
112
- .with_no_client_auth();
113
- let tls = tokio_postgres_rustls::MakeRustlsConnect::new(tls_config);
114
- // Not tokio_postgres::connect(url, tls) -- that treats DATABASE_URL
115
- // as a strict URI, and RDS-generated passwords can contain
116
- // URI-reserved `?`/`#` that CloudFormation never percent-encodes.
117
- let mut config = parse_database_url(&database_url)?;
118
- // RDS requires TLS (rds-ca-bundle.pem); local Postgres over loopback
119
- // can't be verified against the RDS CA, so localhost uses NoTls.
120
- let local_postgres = database_url_is_local(&config);
121
- if !local_postgres {
122
- config.ssl_mode(tokio_postgres::config::SslMode::Require);
123
- }
124
- // NoTlsStream and RustlsStream can't unify in one branch, so the
125
- // two paths are spawned separately.
126
- let connect_phase = log::phase_with("db_connect", serde_json::json!({ "tls": !local_postgres }));
127
- let client = if local_postgres {
128
- let (client, connection) = config
129
- .connect(tokio_postgres::NoTls)
130
- .await
131
- .map_err(|e| format!("connecting to Postgres via DATABASE_URL: {e:?}"))?;
132
- tokio::spawn(async move {
133
- if let Err(e) = connection.await {
134
- eprintln!("postgres connection error: {e:#}");
135
- }
136
- });
137
- client
138
- } else {
139
- let (client, connection) = config
140
- .connect(tls)
141
- .await
142
- .map_err(|e| format!("connecting to Postgres via DATABASE_URL: {e:?}"))?;
143
- tokio::spawn(async move {
144
- if let Err(e) = connection.await {
145
- eprintln!("postgres connection error: {e:#}");
146
- }
147
- });
148
- client
149
- };
150
-
151
- connect_phase.end();
152
-
153
- // Every unqualified table/view reference this binary issues
154
- // resolves through search_path, so this is what makes a shared
155
- // instance's per-domain schemas transparent to the rest of the
156
- // binary.
157
- //
158
- // CREATE SCHEMA before SET search_path: setting search_path to a
159
- // schema that doesn't exist yet succeeds in Postgres, so a missing
160
- // schema only surfaces later as a confusing "no schema has been
161
- // selected to create in" error from `ensure_schema`. Idempotent: an
162
- // existing schema is the ordinary case on every boot after the first.
163
- if let Some(schema) = &schema {
164
- client
165
- .batch_execute(&format!("CREATE SCHEMA IF NOT EXISTS {}", journal::quote_ident(schema)))
166
- .await
167
- .map_err(|e| format!("creating schema {schema:?}: {e:#}"))?;
168
- client
169
- .batch_execute(&format!("SET search_path TO {}", journal::quote_ident(schema)))
170
- .await
171
- .map_err(|e| format!("setting search_path to {schema:?}: {e:#}"))?;
172
- }
173
-
174
- let phase = log::phase("ensure_schema");
175
- journal::ensure_schema(&client)
176
- .await
177
- .map_err(|e| format!("provisioning hecks_lambda_journal: {e:#}"))?;
178
- phase.end();
179
-
180
- let ir = ir::ir().ok_or("HECKS_IR_PATH is not set or unreadable — this binary needs its own domain's ir.json sidecar")?;
181
- ir::refuse_unsupported_persistence_adapters(ir)?;
182
- let my_hash = storage_shape::mint_hash(ir);
183
- let my_label = storage_shape::mint_label(ir);
184
-
185
- let mut aggregates: Vec<mint::Aggregate> = ir::lineage_capable_aggregates(ir)
186
- .into_iter()
187
- .map(|(qualified, storage_name)| {
188
- let name = qualified.rsplit("::").next().unwrap_or(&qualified).to_string();
189
- mint::Aggregate { name, storage_name }
190
- })
191
- .collect();
192
- // A chapter that `provides "membership"` names who may sign in; that
193
- // aggregate can be vendored, so it may be missing from this domain's
194
- // lineage-capable list. Mint its head anyway for Google-auth to read.
195
- if let Some(membership) = ir::membership_provider(ir) {
196
- let name = membership.aggregate.rsplit("::").next().unwrap_or(&membership.aggregate).to_string();
197
- let storage_name = journal::snake(&name);
198
- if !aggregates.iter().any(|a| a.storage_name == storage_name) {
199
- aggregates.push(mint::Aggregate { name, storage_name });
200
- }
201
- }
202
-
203
- // The era-partitioned lineage subsystem is provisioned only when
204
- // something needs it: a lineage-capable aggregate, or Google auth
205
- // configured (ADR 0034 requires it for Member sessions either way).
206
- let needs_lineage = !aggregates.is_empty() || std::env::var("GOOGLE_CLIENT_ID").is_ok();
207
-
208
- let era: Option<i32> = if needs_lineage {
209
- // Idempotent (gated by `provisioner?`); must run before the
210
- // first `journal::held_eras` call, since `hecks_eras` doesn't
211
- // exist yet on a truly fresh Postgres instance.
212
- let phase = log::phase("ensure_base");
213
- mint::ensure_base(&client, &domain).await.map_err(|e| format!("provisioning hecks_eras for {domain}: {e:#}"))?;
214
- phase.end();
215
-
216
- // Compares this binary's own shape hash (`storage_shape`, over
217
- // `ir::ir()`) against held eras to decide the boot action: boot
218
- // at a matching era, mint era 1 if the domain is new, or mint
219
- // the next era if it has drifted -- refusing toward
220
- // `hecks scaffold_translation` (no edge covers it) or
221
- // `hecks audit_translation --approve` (needs review) otherwise.
222
- let era_phase = log::phase("era_resolve");
223
- let held = journal::held_eras(&client, &domain).await.map_err(|e| format!("checking hecks_eras for {domain}: {e:#}"))?;
224
-
225
- // The decision itself is a pure, unit-tested function
226
- // (`mint::decide_boot_action`); everything below just carries
227
- // out whichever of its four outcomes came back.
228
- let decision = mint::decide_boot_action(&held, &my_label);
229
- era_phase.end_with(serde_json::json!({ "held_eras": held.len(), "decision": decision.name() }));
230
- let ordinal: i32 = match decision {
231
- // Adopting an existing era still provisions what this binary
232
- // is about to write into, the same as minting does --
233
- // skipping it would let a matched-era host write into
234
- // head-snapshot tables that were never created.
235
- mint::BootDecision::UseExisting { ordinal } => {
236
- let phase = log::phase_with("adopt_head_snapshots", serde_json::json!({ "era": ordinal }));
237
- mint::adopt_head_snapshots(&client, &domain, &aggregates, ordinal)
238
- .await
239
- .map_err(|e| format!("adopting era {ordinal} of {domain}: {e:#}"))?;
240
- phase.end();
241
- ordinal
242
- }
243
- mint::BootDecision::HoldFirst => {
244
- let source_text =
245
- ir.get("source_text").and_then(serde_json::Value::as_str).ok_or("ir.json is missing source_text — regenerate with hecks project_rust")?;
246
- let phase = log::phase_with("hold_first", serde_json::json!({ "era": 1 }));
247
- mint::hold_first(&client, &domain, source_text, ir, &aggregates, None).await.map_err(|e| format!("minting era 1 of {domain}: {e:#}"))?;
248
- phase.end();
249
- 1
250
- }
251
- mint::BootDecision::LatestUnnamed { ordinal } => {
252
- return Err(format!(
253
- "cannot boot: {domain}'s latest held era (ordinal {ordinal}) has no name yet -- this crate can't \
254
- name it itself (no bluebook parser, ADR 0012's own boundary) -- boot Ruby once against this \
255
- database to name it (Runtime::EraCheck's own lazy-naming path), then redeploy",
256
- )
257
- .into());
258
- }
259
- mint::BootDecision::Mint { ordinal, from_ordinal, from_label } => {
260
- let mut labels: Vec<String> = held.iter().map(|held_era| held_era.label.clone().unwrap_or_default()).collect();
261
- labels.push(my_label.clone());
262
- let edges = mint::parse_edges(ir, &domain);
263
- let chain = mint::edge_chain(&edges, &labels).map_err(|e| {
264
- format!(
265
- "cannot boot: {domain} has drifted from era {from_ordinal} ({from_label}) to a shape \
266
- ({my_label}) no translation edge covers -- {e} -- run hecks scaffold_translation, review it \
267
- with hecks audit_translation, then redeploy",
268
- )
269
- })?;
270
-
271
- // Checks every edge in the chain with a compute/rekey
272
- // rule, not just the newest -- a chain can carry more
273
- // than one, matched against the raw ir.json edge
274
- // (digest-relevant shape) by the same from/to pair.
275
- let raw_edges = ir.get("translations").and_then(serde_json::Value::as_array).cloned().unwrap_or_default();
276
- let committed_approvals = approval::committed(ir);
277
- let approval_phase = log::phase_with("approval_check", serde_json::json!({ "edges": chain.len(), "committed": committed_approvals.len() }));
278
- for edge in &chain {
279
- let Some(raw_edge) = raw_edges.iter().find(|candidate| {
280
- candidate.get("domain").and_then(serde_json::Value::as_str) == Some(domain.as_str())
281
- && candidate.get("from").and_then(serde_json::Value::as_str) == Some(edge.from.as_str())
282
- && candidate.get("to").and_then(serde_json::Value::as_str) == Some(edge.to.as_str())
283
- }) else {
284
- return Err(format!("cannot boot: {domain}'s edge {} -> {} vanished between parsing and approval-checking it", edge.from, edge.to).into());
285
- };
286
- approval::check(&client, &domain, raw_edge, ordinal, &committed_approvals).await.map_err(|e| format!("{e:#}"))?;
287
- }
288
- approval_phase.end();
289
-
290
- // Runs before anything is minted, over the live compiled
291
- // chain (plain SELECTs, never a persisted matview), so a
292
- // refusal leaves no half-born era.
293
- let watermarks: std::collections::HashMap<i32, Option<i64>> = held.iter().map(|held_era| (held_era.ordinal, held_era.watermark)).collect();
294
- let audit_phase = log::phase_with("audit_before_mint", serde_json::json!({ "era": ordinal, "edges": chain.len() }));
295
- mint::audit_before_mint(&client, &domain, ir, &aggregates, ordinal, &chain, &raw_edges, &watermarks)
296
- .await
297
- .map_err(|e| format!("{e:#}"))?;
298
- audit_phase.end();
299
-
300
- let held_text = ir.get("source_text").and_then(serde_json::Value::as_str).ok_or("ir.json is missing source_text — regenerate with hecks project_rust")?;
301
- let mint_phase = log::phase_with("mint_era", serde_json::json!({ "era": ordinal, "edges": chain.len() }));
302
- mint::mint_era(&client, &domain, ordinal, &my_hash, &my_label, held_text, &aggregates, &chain, None, &mint::lifecycle_defaults(ir))
303
- .await
304
- .map_err(|e| format!("minting era {ordinal} of {domain}: {e:#}"))?;
305
- mint_phase.end();
306
- ordinal
307
- }
308
- };
309
- Some(ordinal)
310
- } else {
311
- None
312
- };
313
-
314
- // The mirrored set is the IR's own capable list, by qualified name
315
- // -- never "everything this kernel can mutate". A domain that binds
316
- // everything to plain `Postgres` mirrors nothing.
317
- let mirrored = ir::mirrored_aggregates(ir);
318
- let lineage_config = Arc::new(journal::LineageConfig { domain, era, mirrored: Some(mirrored) });
319
-
320
- // Mutex, not a bare Arc<Client> -- dispatch::handle needs
321
- // Client::transaction (&mut Client) to hold the advisory lock
322
- // across the rehydrate-then-append sequence (see dispatch.rs).
323
- let client = Arc::new(Mutex::new(client));
324
- let wasm_path = Arc::new(wasm_path);
325
- // One invoker for this process's lifetime -- credentials/region
326
- // resolve once at boot, not per invocation. See lambda_client.rs:
327
- // the compiled `.wasm` module can't dispatch this itself (no
328
- // network inside that sandbox).
329
- let invoker = Arc::new(lambda_client::AwsLambdaInvoker::from_env().await);
330
-
331
- // `role` is optional, mirroring `args`: an absent key means no
332
- // caller asserted a role, and `dispatch::handle`'s `check_role`
333
- // stays on its unchecked path. Read by `server::dispatch_body` below.
334
- if serve_mode {
335
- log::info(
336
- "boot",
337
- serde_json::json!({ "mode": "serve", "domain": &lineage_config.domain, "era": &my_label, "boot_ms": boot_started.elapsed().as_millis() as u64 }),
338
- );
339
- let version = server::version_body(&my_label, &my_hash, std::env::var("HECKS_BUILD").ok().as_deref());
340
- let limits = Arc::new(rate_limit::RateLimits::from_env());
341
- let state = server::ServerState { client, wasm_path, lineage_config, invoker, limits };
342
- return server::serve(state, version).await;
343
- }
344
-
345
- lambda_runtime::run(service_fn(move |event: LambdaEvent<serde_json::Value>| {
346
- let client = Arc::clone(&client);
347
- let wasm_path = Arc::clone(&wasm_path);
348
- let lineage_config = Arc::clone(&lineage_config);
349
- let invoker = Arc::clone(&invoker);
350
- async move {
351
- let (body, _context) = event.into_parts();
352
- server::dispatch_body(body, &client, &wasm_path, &lineage_config, invoker.as_ref()).await
353
- }
354
- }))
355
- .await
356
- }
357
-
358
- // Parses `postgres://user:password@host:port/db` without treating it
359
- // as a URI -- an RDS/Aurora auto-generated password can contain
360
- // `?`/`#`, which a strict URI parser would treat as delimiters.
361
- fn parse_database_url(url: &str) -> Result<tokio_postgres::Config, String> {
362
- let rest = url
363
- .strip_prefix("postgres://")
364
- .or_else(|| url.strip_prefix("postgresql://"))
365
- .ok_or_else(|| format!("DATABASE_URL doesn't start with postgres:// or postgresql://"))?;
366
-
367
- let (authority, db_and_query) = rest
368
- .split_once('/')
369
- .ok_or_else(|| "DATABASE_URL has no '/' separating host from database name".to_string())?;
370
- let dbname = db_and_query.split(['?', '#']).next().unwrap_or(db_and_query);
371
-
372
- let (credentials, host_and_port) = match authority.rsplit_once('@') {
373
- Some((credentials, host_and_port)) => (Some(credentials), host_and_port),
374
- None => (None, authority),
375
- };
376
- let (host, port) = match host_and_port.rsplit_once(':') {
377
- Some((host, port)) => {
378
- let port: u16 = port
379
- .parse()
380
- .map_err(|e| format!("DATABASE_URL's port {port:?} isn't a valid number: {e}"))?;
381
- (host, port)
382
- }
383
- None => (host_and_port, 5432),
384
- };
385
-
386
- let mut config = tokio_postgres::Config::new();
387
- config.host(host).port(port).dbname(dbname);
388
- if let Some(credentials) = credentials {
389
- match credentials.split_once(':') {
390
- Some((user, password)) => {
391
- config.user(user).password(password);
392
- }
393
- None => {
394
- config.user(credentials);
395
- }
396
- }
397
- }
398
- Ok(config)
399
- }
400
-
401
- fn database_url_is_local(config: &tokio_postgres::Config) -> bool {
402
- use tokio_postgres::config::Host;
403
- config.get_hosts().iter().any(|host| match host {
404
- Host::Tcp(name) => name == "localhost" || name == "127.0.0.1" || name == "::1",
405
- _ => false,
406
- })
407
- }
408
-
409
- #[cfg(test)]
410
- mod tests {
411
- use super::*;
412
-
413
- #[test]
414
- fn parses_a_password_with_uri_reserved_characters() {
415
- let config = parse_database_url(
416
- "postgres://postgres:T3st$Pa?#ss*>Word@db.example.com:5432/pizzas",
417
- )
418
- .expect("should parse despite ?/# in the password");
419
- assert_eq!(config.get_hosts().len(), 1);
420
- assert_eq!(config.get_ports(), &[5432]);
421
- assert_eq!(config.get_user(), Some("postgres"));
422
- assert_eq!(config.get_dbname(), Some("pizzas"));
423
- }
424
-
425
- #[test]
426
- fn parses_a_plain_password_too() {
427
- let config = parse_database_url("postgres://postgres:plainpassword@localhost:5432/pizzas")
428
- .expect("should parse a password with no special characters");
429
- assert_eq!(config.get_user(), Some("postgres"));
430
- assert_eq!(config.get_dbname(), Some("pizzas"));
431
- }
432
-
433
- #[test]
434
- fn parses_a_local_url_with_no_user_or_port() {
435
- let config = parse_database_url("postgres://localhost/app_development")
436
- .expect("should parse a peer-auth local URL");
437
- assert_eq!(config.get_dbname(), Some("app_development"));
438
- assert_eq!(config.get_ports(), &[5432]);
439
- assert!(database_url_is_local(&config));
440
- }
5
+ async fn main() -> Result<(), lambda_runtime::Error> {
6
+ rust_host::boot::run().await
441
7
  }
@@ -75,6 +75,12 @@ pub fn extract_field(secret_json: &str, field: &str) -> Result<String, String> {
75
75
  .ok_or_else(|| format!("secret's SecretString has no string {field:?} field"))
76
76
  }
77
77
 
78
+ /// One named string field out of a secret's JSON `SecretString`, or `None` when the secret does not
79
+ /// carry it (or is not JSON, or the field is blank). For a field a secret may have and may not.
80
+ pub fn optional_field(secret_json: &str, field: &str) -> Option<String> {
81
+ extract_field(secret_json, field).ok().filter(|value| !value.trim().is_empty())
82
+ }
83
+
78
84
  /// The `postgres://` URL for a database secret.
79
85
  ///
80
86
  /// The user is the secret's `username`, which is `postgres` for RDS's managed master secret and a
@@ -96,6 +102,15 @@ pub fn database_url(secret_json: &str, host: &str, database: &str) -> Result<Str
96
102
  mod tests {
97
103
  use super::*;
98
104
 
105
+ #[test]
106
+ fn an_optional_field_is_read_when_present_and_none_when_absent_blank_or_not_json() {
107
+ let json = r#"{"api_key":"re_1","webhook_secret":"whsec_abc","blank":" "}"#;
108
+ assert_eq!(optional_field(json, "webhook_secret").as_deref(), Some("whsec_abc"));
109
+ assert_eq!(optional_field(json, "missing"), None);
110
+ assert_eq!(optional_field(json, "blank"), None);
111
+ assert_eq!(optional_field("not json", "webhook_secret"), None);
112
+ }
113
+
99
114
  #[test]
100
115
  fn a_master_secret_logs_in_as_postgres_on_the_default_port() {
101
116
  let json = r#"{"username":"postgres","password":"abc123"}"#;
@@ -101,12 +101,20 @@ impl HostExtension for Commerce {
101
101
  }
102
102
 
103
103
  // RESEND_SECRET_ID: a failed fetch only logs -- mail is optional, and a missing secret must not
104
- // stop the host from serving everything else (resend.rs answers 503 without a key).
104
+ // stop the host from serving everything else (resend.rs answers 503 without a key). The same
105
+ // secret may also carry `webhook_secret`, the signing secret of the Resend webhook that
106
+ // POST /webhooks/resend verifies; without it that route answers 503.
105
107
  async fn boot_secrets(&self, fetcher: &AwsSecretFetcher) {
106
108
  let Ok(resend_secret_id) = std::env::var("RESEND_SECRET_ID") else { return };
107
109
  let fetched = fetcher.fetch_secret_string(&resend_secret_id).await.map_err(|e| format!("{e:#}"));
108
- match fetched.and_then(|json| secrets::extract_field(&json, "api_key")) {
109
- Ok(api_key) => unsafe { std::env::set_var("RESEND_API_KEY", api_key) },
110
+ let read = fetched.and_then(|json| secrets::extract_field(&json, "api_key").map(|api_key| (api_key, secrets::optional_field(&json, "webhook_secret"))));
111
+ match read {
112
+ Ok((api_key, webhook_secret)) => unsafe {
113
+ std::env::set_var("RESEND_API_KEY", api_key);
114
+ if let Some(webhook_secret) = webhook_secret {
115
+ std::env::set_var("RESEND_WEBHOOK_SECRET", webhook_secret);
116
+ }
117
+ },
110
118
  Err(e) => eprintln!("RESEND_SECRET_ID could not be read, so email sending stays off: {e}"),
111
119
  }
112
120
  }
@@ -18,7 +18,8 @@ type HmacSha256 = Hmac<Sha256>;
18
18
  // its Delivery (`RecordOpen` / `RecordClick`, which refuse a repeat). The
19
19
  // delivery is found by the Resend message id stored when the issue was sent.
20
20
  // Served under the same gate as the other newsletter routes, and only when
21
- // RESEND_WEBHOOK_SECRET (the endpoint's `whsec_` signing secret) is set.
21
+ // RESEND_WEBHOOK_SECRET (the endpoint's `whsec_` signing secret) is set, either
22
+ // directly or from the `webhook_secret` field of the secret RESEND_SECRET_ID names.
22
23
 
23
24
  const SECRET_VARIABLE: &str = "RESEND_WEBHOOK_SECRET";
24
25
  // Resend signs with Svix, whose default replay window is five minutes.
@@ -328,6 +328,12 @@ fn dispatch_operator(category: OperatorCategory, expr: &Expr, ctx: &EvalContext)
328
328
  /// refuse here — canonical text is validated at boot, so any refusal
329
329
  /// past this point is a codegen bug, not a real business refusal.
330
330
  fn lookup(path: &str, ctx: &EvalContext) -> Result<Value, Refusal> {
331
+ composite::finish(lookup_field(path, ctx)?, path)
332
+ }
333
+
334
+ /// `lookup`'s walk, stopped before the last field collapses to a scalar: a path may end on a
335
+ /// nested object, which `.set?` reads as present without needing it to be one value.
336
+ pub(crate) fn lookup_field<'a>(path: &str, ctx: &EvalContext<'a>) -> Result<Field<'a>, Refusal> {
331
337
  let mut segments = path.split('.');
332
338
  let head = segments.next().unwrap();
333
339
  let mut current = ctx
@@ -340,7 +346,7 @@ fn lookup(path: &str, ctx: &EvalContext) -> Result<Value, Refusal> {
340
346
  current = composite::step(current, seg, head)?;
341
347
  }
342
348
 
343
- composite::finish(current, path)
349
+ Ok(current)
344
350
  }
345
351
 
346
352
  /// The list-elements reading of a `Lookup` path — same head resolution
@@ -1,7 +1,7 @@
1
1
  //! Interprets `.present?`/`.blank?` and `.set?`/`.unset?`.
2
2
  //! The two pairs share one operator category and differ in how they treat empty values.
3
3
 
4
- use crate::kernel::expr::{interpret as eval, EvalContext, Expr, Value};
4
+ use crate::kernel::expr::{interpret as eval, lookup_field, EvalContext, Expr, Field, Value};
5
5
  use crate::kernel::Refusal;
6
6
 
7
7
  pub fn interpret(expr: &Expr, ctx: &EvalContext) -> Result<Value, Refusal> {
@@ -20,8 +20,14 @@ pub fn interpret_assignment(expr: &Expr, ctx: &EvalContext) -> Result<Value, Ref
20
20
  return Err(Refusal::TypeMismatch(format!("presence::interpret_assignment called with a non-assignment node {expr:?} — a router bug")));
21
21
  };
22
22
 
23
- let v = eval(receiver, ctx)?;
24
- let set = !matches!(v, Value::Nil);
23
+ let set = match &**receiver {
24
+ // A nested object (an optional value-object attribute) is set whenever it is present.
25
+ Expr::Lookup(path) => match lookup_field(path, ctx)? {
26
+ Field::Nested(_) => true,
27
+ Field::Value(v) => !matches!(v, Value::Nil),
28
+ },
29
+ other => !matches!(eval(other, ctx)?, Value::Nil),
30
+ };
25
31
  Ok(Value::Bool(if *negated { !set } else { set }))
26
32
  }
27
33
 
@@ -93,6 +99,38 @@ mod tests {
93
99
  assert_eq!(set(Expr::Array(vec![]), false), Value::Bool(true));
94
100
  }
95
101
 
102
+ // A record with one value-object field (`body`, holding a nested object or nothing).
103
+ struct Draft {
104
+ body: Option<Body>,
105
+ }
106
+ struct Body;
107
+ impl crate::kernel::expr::Fielded for Body {
108
+ fn field(&self, _name: &str) -> Option<Field<'_>> {
109
+ None
110
+ }
111
+ }
112
+ impl crate::kernel::expr::Fielded for Draft {
113
+ fn field(&self, name: &str) -> Option<Field<'_>> {
114
+ match (name, &self.body) {
115
+ ("body", Some(body)) => Some(Field::Nested(body)),
116
+ ("body", None) => Some(Field::Value(Value::Nil)),
117
+ _ => None,
118
+ }
119
+ }
120
+ }
121
+
122
+ fn set_on(draft: &Draft, negated: bool) -> Value {
123
+ let expr = Expr::Assignment { receiver: Box::new(Expr::Lookup("body")), negated };
124
+ eval(&expr, &EvalContext { args: &NoFields, instance: draft }).expect("evaluates")
125
+ }
126
+
127
+ #[test]
128
+ fn a_nested_object_field_is_set_and_an_absent_one_is_not() {
129
+ assert_eq!(set_on(&Draft { body: Some(Body) }, false), Value::Bool(true));
130
+ assert_eq!(set_on(&Draft { body: None }, false), Value::Bool(false));
131
+ assert_eq!(set_on(&Draft { body: Some(Body) }, true), Value::Bool(false));
132
+ }
133
+
96
134
  #[test]
97
135
  fn unset_negates_set() {
98
136
  assert_eq!(set(Expr::Nil, true), Value::Bool(true));