hecks 3.7.0 → 3.9.1

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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/bluebook/meta_validator/modes.rb +14 -0
  3. data/lib/hecks/bluebook/meta_validator.rb +4 -2
  4. data/lib/hecks/corpus/routes.rb +1 -1
  5. data/lib/hecks/gate/stages.yml +11 -1
  6. data/lib/hecks/hecks/adapters/site_toolchain/prober.rb +64 -0
  7. data/lib/hecks/hecks/adapters/site_toolchain.rb +41 -5
  8. data/lib/hecks/language/bluebook/vocabulary.bluebook +1 -1
  9. data/lib/hecks/projections/deploy/shared_database_instance/templates/rds.yaml.tmpl +1 -1
  10. data/lib/hecks/projections/deploy/templates/lambda/template.yaml.tmpl +1 -1
  11. data/lib/hecks/projections/site/probe.rb +131 -0
  12. data/lib/hecks/projections/site/site_host/dockerfile.rb +1 -1
  13. data/lib/hecks/runtime/value/validation.rb +34 -3
  14. data/lib/hecks/site/bluebook/site.bluebook +31 -0
  15. data/lib/hecks/site/bluebook/site.ports.hecksagon +6 -0
  16. data/lib/hecks/version.rb +1 -1
  17. data/lib/hecks/vocabulary.rb +1 -1
  18. data/prebuilt/{verdicts-be73f0df9eee0abe2f4aadc968321c4b919d19d424a3ccfe1777a71dabf7d9e7.json → verdicts-91aaf93ce8981bd9a754948f075d95650dc2ec6435015376f5c60992b36a24b1.json} +1 -1
  19. data/rust/codegen/src/fielded.rs +3 -3
  20. data/rust/codegen/src/json_codec.rs +2 -2
  21. data/rust/codegen/src/naming.rs +20 -0
  22. data/rust/host/Cargo.toml +4 -0
  23. data/rust/host/HECKS_RELEASE +1 -1
  24. data/rust/host/src/boot.rs +443 -0
  25. data/rust/host/src/boundary_fuzz/ir.rs +1 -0
  26. data/rust/host/src/commerce_ir.rs +459 -0
  27. data/rust/host/src/extension.rs +100 -0
  28. data/rust/host/src/ir.rs +0 -448
  29. data/rust/host/src/journal.rs +1 -1
  30. data/rust/host/src/lib.rs +40 -0
  31. data/rust/host/src/main.rs +4 -477
  32. data/rust/host/src/mint.rs +1 -1
  33. data/rust/host/src/payments/tests.rs +8 -8
  34. data/rust/host/src/payments.rs +10 -10
  35. data/rust/host/src/rate_limit.rs +27 -41
  36. data/rust/host/src/secrets.rs +15 -0
  37. data/rust/host/src/web/commerce.rs +121 -0
  38. data/rust/host/src/web/newsletter.rs +2 -1
  39. data/rust/host/src/web/newsletter_send.rs +1 -1
  40. data/rust/host/src/web/registration_receipt.rs +2 -2
  41. data/rust/host/src/web/registrations.rs +49 -49
  42. data/rust/host/src/web/resend_webhook.rs +300 -0
  43. data/rust/host/src/web.rs +43 -47
  44. metadata +13 -5
  45. /data/prebuilt/{a0818e2de67364dc1ab22868b6cbf869c62da6f8d9b924c778fa5cce2e76ee94.marshal → 0ee7f83cba59ce7d1c45c65ba166b071779f70143449f584f33ff7989f64eccb.marshal} +0 -0
  46. /data/prebuilt/{945481eefbdb584649d45f256307053558966a868b9c38dee7ee88f8169610f8.marshal → 6e4a6bcf0aa880e2e6b056e1846b70e5a768090b9dc998bbadeaf5d6bcfd3db8.marshal} +0 -0
@@ -1,480 +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
- mod api;
5
- mod approval;
6
- mod auth;
7
- mod checkout;
8
- mod dispatch;
9
- mod expr_json;
10
- mod field_hints;
11
- #[cfg(test)]
12
- mod fuzz_support;
13
- #[cfg(test)]
14
- #[path = "boundary_fuzz/expr_json.rs"]
15
- mod expr_json_fuzz;
16
- mod ir;
17
- mod journal;
18
- mod lambda_client;
19
- mod log;
20
- mod mint;
21
- mod needs;
22
- mod payments;
23
- mod presentation;
24
- mod presentation_write;
25
- mod query_step;
26
- mod rate_limit;
27
- mod reference_transform;
28
- mod reference_validate;
29
- mod resend;
30
- mod secrets;
31
- mod server;
32
- mod storage_shape;
33
- #[cfg(test)]
34
- mod test_pg;
35
- mod ui_schema;
36
- mod wasm_runner;
37
- mod web;
38
-
39
- use lambda_runtime::{service_fn, Error, LambdaEvent};
40
- use std::path::PathBuf;
41
- use std::sync::Arc;
42
- 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`.
43
3
 
44
4
  #[tokio::main]
45
- async fn main() -> Result<(), Error> {
46
- // HECKS_SERVE_MODE=1 switches the boot into the long-lived server
47
- // loop below instead of the Lambda custom-runtime loop; every step
48
- // from here through the era-minting sequence is shared, unchanged,
49
- // by both modes.
50
- let boot_started = std::time::Instant::now();
51
- let serve_mode = std::env::var("HECKS_SERVE_MODE").as_deref() == Ok("1");
52
-
53
- // HECKS_SESSION_COOKIE names the account cookie; refuse the boot
54
- // here if it can't be written into a Set-Cookie header, rather
55
- // than falling back silently at request time.
56
- auth::resolve_account_cookie(std::env::var("HECKS_SESSION_COOKIE").ok().as_deref())?;
57
-
58
- // DB_SECRET_ARN: the password is fetched from Secrets Manager here,
59
- // at cold start, rather than trusted from a plaintext
60
- // Environment.Variables value, which any principal with
61
- // lambda:GetFunctionConfiguration could read regardless of the
62
- // secret-at-rest protection its name suggests. Falls back to
63
- // DATABASE_URL directly when DB_SECRET_ARN is absent (manual
64
- // debugging over an SSM tunnel).
65
- //
66
- // The fetcher built here is reused below for GOOGLE_OAUTH_SECRET_ID
67
- // and SESSION_SECRET_ARN too.
68
- let secret_fetcher = secrets::AwsSecretFetcher::from_env().await;
69
- let database_url = match std::env::var("DB_SECRET_ARN") {
70
- Ok(secret_arn) => {
71
- let db_host = std::env::var("DB_HOST").map_err(|_| "DB_HOST is required when DB_SECRET_ARN is set")?;
72
- let db_name = std::env::var("DB_NAME").map_err(|_| "DB_NAME is required when DB_SECRET_ARN is set")?;
73
- let secret_json = secret_fetcher
74
- .fetch_secret_string(&secret_arn)
75
- .await
76
- .map_err(|e| format!("fetching DB_SECRET_ARN from Secrets Manager: {e:#}"))?;
77
- // The user and port come from the secret too (a client sharing an instance logs in as
78
- // its own role); the password goes in literal, since parse_database_url below never
79
- // percent-decodes it.
80
- secrets::database_url(&secret_json, &db_host, &db_name)?
81
- }
82
- Err(_) => std::env::var("DATABASE_URL")
83
- .map_err(|_| "either DB_SECRET_ARN (+ DB_HOST/DB_NAME) or DATABASE_URL is required")?,
84
- };
85
-
86
- // GOOGLE_OAUTH_SECRET_ID/SESSION_SECRET_ARN: the same
87
- // Secrets-Manager-at-cold-start approach as DB_SECRET_ARN above,
88
- // applied to auth.rs's GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET and
89
- // web.rs's SESSION_SECRET.
90
- //
91
- // Safety: this runs before any request is dispatched, so nothing
92
- // else reads or writes the environment concurrently with these
93
- // `set_var` calls.
94
- if let Ok(oauth_secret_id) = std::env::var("GOOGLE_OAUTH_SECRET_ID") {
95
- let secret_json = secret_fetcher
96
- .fetch_secret_string(&oauth_secret_id)
97
- .await
98
- .map_err(|e| format!("fetching GOOGLE_OAUTH_SECRET_ID from Secrets Manager: {e:#}"))?;
99
- let client_id = secrets::extract_field(&secret_json, "client_id")?;
100
- let client_secret = secrets::extract_field(&secret_json, "client_secret")?;
101
- unsafe {
102
- std::env::set_var("GOOGLE_CLIENT_ID", client_id);
103
- std::env::set_var("GOOGLE_CLIENT_SECRET", client_secret);
104
- }
105
- }
106
- if let Ok(session_secret_arn) = std::env::var("SESSION_SECRET_ARN") {
107
- let secret_json = secret_fetcher
108
- .fetch_secret_string(&session_secret_arn)
109
- .await
110
- .map_err(|e| format!("fetching SESSION_SECRET_ARN from Secrets Manager: {e:#}"))?;
111
- let session_secret = secrets::extract_field(&secret_json, "session_secret")?;
112
- unsafe {
113
- std::env::set_var("SESSION_SECRET", session_secret);
114
- }
115
- }
116
- // RESEND_SECRET_ID: fetched the same way, but a failed fetch only
117
- // logs -- mail is optional, and a missing secret must not stop the
118
- // host from serving everything else (resend.rs answers 503 without
119
- // a key).
120
- if let Ok(resend_secret_id) = std::env::var("RESEND_SECRET_ID") {
121
- let fetched = secret_fetcher.fetch_secret_string(&resend_secret_id).await.map_err(|e| format!("{e:#}"));
122
- match fetched.and_then(|json| secrets::extract_field(&json, "api_key")) {
123
- Ok(api_key) => unsafe { std::env::set_var("RESEND_API_KEY", api_key) },
124
- Err(e) => eprintln!("RESEND_SECRET_ID could not be read, so email sending stays off: {e}"),
125
- }
126
- }
127
- let wasm_path = PathBuf::from(
128
- std::env::var("HECKS_WASM_PATH").unwrap_or_else(|_| "banking.wasm".to_string()),
129
- );
130
-
131
- // Must run before the Postgres connection opens -- `SET search_path`
132
- // below has to run before `journal::ensure_schema` or anything else
133
- // touches the connection, or unqualified names resolve against
134
- // Postgres's default search_path instead of this domain's.
135
- // HECKS_SCHEMA is optional.
136
- let domain = std::env::var("HECKS_DOMAIN").map_err(|_| "HECKS_DOMAIN is required")?;
137
- let schema = std::env::var("HECKS_SCHEMA").ok().filter(|s| !s.is_empty());
138
- // Checkout on AWS keeps the business's payment keys in a named Secrets
139
- // Manager secret; refuse before touching the database when none is named.
140
- payments::check_boot(web::checkout_enabled(std::env::var("HECKS_CHECKOUT_DOMAIN").ok().as_deref(), &domain))?;
141
-
142
- // RDS refuses a plain NoTls connection by default and needs AWS's
143
- // own RDS CA specifically, not a generic public bundle (see
144
- // Cargo.toml).
145
- let mut roots = rustls::RootCertStore::empty();
146
- for cert in rustls_pemfile::certs(&mut include_bytes!("../rds-ca-bundle.pem").as_slice()) {
147
- roots.add(cert.map_err(|e| format!("parsing rds-ca-bundle.pem: {e}"))?)?;
148
- }
149
- let tls_config = rustls::ClientConfig::builder()
150
- .with_root_certificates(roots)
151
- .with_no_client_auth();
152
- let tls = tokio_postgres_rustls::MakeRustlsConnect::new(tls_config);
153
- // Not tokio_postgres::connect(url, tls) -- that treats DATABASE_URL
154
- // as a strict URI, and RDS-generated passwords can contain
155
- // URI-reserved `?`/`#` that CloudFormation never percent-encodes.
156
- let mut config = parse_database_url(&database_url)?;
157
- // RDS requires TLS (rds-ca-bundle.pem); local Postgres over loopback
158
- // can't be verified against the RDS CA, so localhost uses NoTls.
159
- let local_postgres = database_url_is_local(&config);
160
- if !local_postgres {
161
- config.ssl_mode(tokio_postgres::config::SslMode::Require);
162
- }
163
- // NoTlsStream and RustlsStream can't unify in one branch, so the
164
- // two paths are spawned separately.
165
- let connect_phase = log::phase_with("db_connect", serde_json::json!({ "tls": !local_postgres }));
166
- let client = if local_postgres {
167
- let (client, connection) = config
168
- .connect(tokio_postgres::NoTls)
169
- .await
170
- .map_err(|e| format!("connecting to Postgres via DATABASE_URL: {e:?}"))?;
171
- tokio::spawn(async move {
172
- if let Err(e) = connection.await {
173
- eprintln!("postgres connection error: {e:#}");
174
- }
175
- });
176
- client
177
- } else {
178
- let (client, connection) = config
179
- .connect(tls)
180
- .await
181
- .map_err(|e| format!("connecting to Postgres via DATABASE_URL: {e:?}"))?;
182
- tokio::spawn(async move {
183
- if let Err(e) = connection.await {
184
- eprintln!("postgres connection error: {e:#}");
185
- }
186
- });
187
- client
188
- };
189
-
190
- connect_phase.end();
191
-
192
- // Every unqualified table/view reference this binary issues
193
- // resolves through search_path, so this is what makes a shared
194
- // instance's per-domain schemas transparent to the rest of the
195
- // binary.
196
- //
197
- // CREATE SCHEMA before SET search_path: setting search_path to a
198
- // schema that doesn't exist yet succeeds in Postgres, so a missing
199
- // schema only surfaces later as a confusing "no schema has been
200
- // selected to create in" error from `ensure_schema`. Idempotent: an
201
- // existing schema is the ordinary case on every boot after the first.
202
- if let Some(schema) = &schema {
203
- client
204
- .batch_execute(&format!("CREATE SCHEMA IF NOT EXISTS {}", journal::quote_ident(schema)))
205
- .await
206
- .map_err(|e| format!("creating schema {schema:?}: {e:#}"))?;
207
- client
208
- .batch_execute(&format!("SET search_path TO {}", journal::quote_ident(schema)))
209
- .await
210
- .map_err(|e| format!("setting search_path to {schema:?}: {e:#}"))?;
211
- }
212
-
213
- let phase = log::phase("ensure_schema");
214
- journal::ensure_schema(&client)
215
- .await
216
- .map_err(|e| format!("provisioning hecks_lambda_journal: {e:#}"))?;
217
- phase.end();
218
-
219
- let ir = ir::ir().ok_or("HECKS_IR_PATH is not set or unreadable — this binary needs its own domain's ir.json sidecar")?;
220
- ir::refuse_unsupported_persistence_adapters(ir)?;
221
- let my_hash = storage_shape::mint_hash(ir);
222
- let my_label = storage_shape::mint_label(ir);
223
-
224
- let mut aggregates: Vec<mint::Aggregate> = ir::lineage_capable_aggregates(ir)
225
- .into_iter()
226
- .map(|(qualified, storage_name)| {
227
- let name = qualified.rsplit("::").next().unwrap_or(&qualified).to_string();
228
- mint::Aggregate { name, storage_name }
229
- })
230
- .collect();
231
- // A chapter that `provides "membership"` names who may sign in; that
232
- // aggregate can be vendored, so it may be missing from this domain's
233
- // lineage-capable list. Mint its head anyway for Google-auth to read.
234
- if let Some(membership) = ir::membership_provider(ir) {
235
- let name = membership.aggregate.rsplit("::").next().unwrap_or(&membership.aggregate).to_string();
236
- let storage_name = journal::snake(&name);
237
- if !aggregates.iter().any(|a| a.storage_name == storage_name) {
238
- aggregates.push(mint::Aggregate { name, storage_name });
239
- }
240
- }
241
-
242
- // The era-partitioned lineage subsystem is provisioned only when
243
- // something needs it: a lineage-capable aggregate, or Google auth
244
- // configured (ADR 0034 requires it for Member sessions either way).
245
- let needs_lineage = !aggregates.is_empty() || std::env::var("GOOGLE_CLIENT_ID").is_ok();
246
-
247
- let era: Option<i32> = if needs_lineage {
248
- // Idempotent (gated by `provisioner?`); must run before the
249
- // first `journal::held_eras` call, since `hecks_eras` doesn't
250
- // exist yet on a truly fresh Postgres instance.
251
- let phase = log::phase("ensure_base");
252
- mint::ensure_base(&client, &domain).await.map_err(|e| format!("provisioning hecks_eras for {domain}: {e:#}"))?;
253
- phase.end();
254
-
255
- // Compares this binary's own shape hash (`storage_shape`, over
256
- // `ir::ir()`) against held eras to decide the boot action: boot
257
- // at a matching era, mint era 1 if the domain is new, or mint
258
- // the next era if it has drifted -- refusing toward
259
- // `hecks scaffold_translation` (no edge covers it) or
260
- // `hecks audit_translation --approve` (needs review) otherwise.
261
- let era_phase = log::phase("era_resolve");
262
- let held = journal::held_eras(&client, &domain).await.map_err(|e| format!("checking hecks_eras for {domain}: {e:#}"))?;
263
-
264
- // The decision itself is a pure, unit-tested function
265
- // (`mint::decide_boot_action`); everything below just carries
266
- // out whichever of its four outcomes came back.
267
- let decision = mint::decide_boot_action(&held, &my_label);
268
- era_phase.end_with(serde_json::json!({ "held_eras": held.len(), "decision": decision.name() }));
269
- let ordinal: i32 = match decision {
270
- // Adopting an existing era still provisions what this binary
271
- // is about to write into, the same as minting does --
272
- // skipping it would let a matched-era host write into
273
- // head-snapshot tables that were never created.
274
- mint::BootDecision::UseExisting { ordinal } => {
275
- let phase = log::phase_with("adopt_head_snapshots", serde_json::json!({ "era": ordinal }));
276
- mint::adopt_head_snapshots(&client, &domain, &aggregates, ordinal)
277
- .await
278
- .map_err(|e| format!("adopting era {ordinal} of {domain}: {e:#}"))?;
279
- phase.end();
280
- ordinal
281
- }
282
- mint::BootDecision::HoldFirst => {
283
- let source_text =
284
- ir.get("source_text").and_then(serde_json::Value::as_str).ok_or("ir.json is missing source_text — regenerate with hecks project_rust")?;
285
- let phase = log::phase_with("hold_first", serde_json::json!({ "era": 1 }));
286
- mint::hold_first(&client, &domain, source_text, ir, &aggregates, None).await.map_err(|e| format!("minting era 1 of {domain}: {e:#}"))?;
287
- phase.end();
288
- 1
289
- }
290
- mint::BootDecision::LatestUnnamed { ordinal } => {
291
- return Err(format!(
292
- "cannot boot: {domain}'s latest held era (ordinal {ordinal}) has no name yet -- this crate can't \
293
- name it itself (no bluebook parser, ADR 0012's own boundary) -- boot Ruby once against this \
294
- database to name it (Runtime::EraCheck's own lazy-naming path), then redeploy",
295
- )
296
- .into());
297
- }
298
- mint::BootDecision::Mint { ordinal, from_ordinal, from_label } => {
299
- let mut labels: Vec<String> = held.iter().map(|held_era| held_era.label.clone().unwrap_or_default()).collect();
300
- labels.push(my_label.clone());
301
- let edges = mint::parse_edges(ir, &domain);
302
- let chain = mint::edge_chain(&edges, &labels).map_err(|e| {
303
- format!(
304
- "cannot boot: {domain} has drifted from era {from_ordinal} ({from_label}) to a shape \
305
- ({my_label}) no translation edge covers -- {e} -- run hecks scaffold_translation, review it \
306
- with hecks audit_translation, then redeploy",
307
- )
308
- })?;
309
-
310
- // Checks every edge in the chain with a compute/rekey
311
- // rule, not just the newest -- a chain can carry more
312
- // than one, matched against the raw ir.json edge
313
- // (digest-relevant shape) by the same from/to pair.
314
- let raw_edges = ir.get("translations").and_then(serde_json::Value::as_array).cloned().unwrap_or_default();
315
- let committed_approvals = approval::committed(ir);
316
- let approval_phase = log::phase_with("approval_check", serde_json::json!({ "edges": chain.len(), "committed": committed_approvals.len() }));
317
- for edge in &chain {
318
- let Some(raw_edge) = raw_edges.iter().find(|candidate| {
319
- candidate.get("domain").and_then(serde_json::Value::as_str) == Some(domain.as_str())
320
- && candidate.get("from").and_then(serde_json::Value::as_str) == Some(edge.from.as_str())
321
- && candidate.get("to").and_then(serde_json::Value::as_str) == Some(edge.to.as_str())
322
- }) else {
323
- return Err(format!("cannot boot: {domain}'s edge {} -> {} vanished between parsing and approval-checking it", edge.from, edge.to).into());
324
- };
325
- approval::check(&client, &domain, raw_edge, ordinal, &committed_approvals).await.map_err(|e| format!("{e:#}"))?;
326
- }
327
- approval_phase.end();
328
-
329
- // Runs before anything is minted, over the live compiled
330
- // chain (plain SELECTs, never a persisted matview), so a
331
- // refusal leaves no half-born era.
332
- let watermarks: std::collections::HashMap<i32, Option<i64>> = held.iter().map(|held_era| (held_era.ordinal, held_era.watermark)).collect();
333
- let audit_phase = log::phase_with("audit_before_mint", serde_json::json!({ "era": ordinal, "edges": chain.len() }));
334
- mint::audit_before_mint(&client, &domain, ir, &aggregates, ordinal, &chain, &raw_edges, &watermarks)
335
- .await
336
- .map_err(|e| format!("{e:#}"))?;
337
- audit_phase.end();
338
-
339
- 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")?;
340
- let mint_phase = log::phase_with("mint_era", serde_json::json!({ "era": ordinal, "edges": chain.len() }));
341
- mint::mint_era(&client, &domain, ordinal, &my_hash, &my_label, held_text, &aggregates, &chain, None, &mint::lifecycle_defaults(ir))
342
- .await
343
- .map_err(|e| format!("minting era {ordinal} of {domain}: {e:#}"))?;
344
- mint_phase.end();
345
- ordinal
346
- }
347
- };
348
- Some(ordinal)
349
- } else {
350
- None
351
- };
352
-
353
- // The mirrored set is the IR's own capable list, by qualified name
354
- // -- never "everything this kernel can mutate". A domain that binds
355
- // everything to plain `Postgres` mirrors nothing.
356
- let mirrored = ir::mirrored_aggregates(ir);
357
- let lineage_config = Arc::new(journal::LineageConfig { domain, era, mirrored: Some(mirrored) });
358
-
359
- // Mutex, not a bare Arc<Client> -- dispatch::handle needs
360
- // Client::transaction (&mut Client) to hold the advisory lock
361
- // across the rehydrate-then-append sequence (see dispatch.rs).
362
- let client = Arc::new(Mutex::new(client));
363
- let wasm_path = Arc::new(wasm_path);
364
- // One invoker for this process's lifetime -- credentials/region
365
- // resolve once at boot, not per invocation. See lambda_client.rs:
366
- // the compiled `.wasm` module can't dispatch this itself (no
367
- // network inside that sandbox).
368
- let invoker = Arc::new(lambda_client::AwsLambdaInvoker::from_env().await);
369
-
370
- // `role` is optional, mirroring `args`: an absent key means no
371
- // caller asserted a role, and `dispatch::handle`'s `check_role`
372
- // stays on its unchecked path. Read by `server::dispatch_body` below.
373
- if serve_mode {
374
- log::info(
375
- "boot",
376
- serde_json::json!({ "mode": "serve", "domain": &lineage_config.domain, "era": &my_label, "boot_ms": boot_started.elapsed().as_millis() as u64 }),
377
- );
378
- let version = server::version_body(&my_label, &my_hash, std::env::var("HECKS_BUILD").ok().as_deref());
379
- let limits = Arc::new(rate_limit::RateLimits::from_env());
380
- let state = server::ServerState { client, wasm_path, lineage_config, invoker, limits };
381
- return server::serve(state, version).await;
382
- }
383
-
384
- lambda_runtime::run(service_fn(move |event: LambdaEvent<serde_json::Value>| {
385
- let client = Arc::clone(&client);
386
- let wasm_path = Arc::clone(&wasm_path);
387
- let lineage_config = Arc::clone(&lineage_config);
388
- let invoker = Arc::clone(&invoker);
389
- async move {
390
- let (body, _context) = event.into_parts();
391
- server::dispatch_body(body, &client, &wasm_path, &lineage_config, invoker.as_ref()).await
392
- }
393
- }))
394
- .await
395
- }
396
-
397
- // Parses `postgres://user:password@host:port/db` without treating it
398
- // as a URI -- an RDS/Aurora auto-generated password can contain
399
- // `?`/`#`, which a strict URI parser would treat as delimiters.
400
- fn parse_database_url(url: &str) -> Result<tokio_postgres::Config, String> {
401
- let rest = url
402
- .strip_prefix("postgres://")
403
- .or_else(|| url.strip_prefix("postgresql://"))
404
- .ok_or_else(|| format!("DATABASE_URL doesn't start with postgres:// or postgresql://"))?;
405
-
406
- let (authority, db_and_query) = rest
407
- .split_once('/')
408
- .ok_or_else(|| "DATABASE_URL has no '/' separating host from database name".to_string())?;
409
- let dbname = db_and_query.split(['?', '#']).next().unwrap_or(db_and_query);
410
-
411
- let (credentials, host_and_port) = match authority.rsplit_once('@') {
412
- Some((credentials, host_and_port)) => (Some(credentials), host_and_port),
413
- None => (None, authority),
414
- };
415
- let (host, port) = match host_and_port.rsplit_once(':') {
416
- Some((host, port)) => {
417
- let port: u16 = port
418
- .parse()
419
- .map_err(|e| format!("DATABASE_URL's port {port:?} isn't a valid number: {e}"))?;
420
- (host, port)
421
- }
422
- None => (host_and_port, 5432),
423
- };
424
-
425
- let mut config = tokio_postgres::Config::new();
426
- config.host(host).port(port).dbname(dbname);
427
- if let Some(credentials) = credentials {
428
- match credentials.split_once(':') {
429
- Some((user, password)) => {
430
- config.user(user).password(password);
431
- }
432
- None => {
433
- config.user(credentials);
434
- }
435
- }
436
- }
437
- Ok(config)
438
- }
439
-
440
- fn database_url_is_local(config: &tokio_postgres::Config) -> bool {
441
- use tokio_postgres::config::Host;
442
- config.get_hosts().iter().any(|host| match host {
443
- Host::Tcp(name) => name == "localhost" || name == "127.0.0.1" || name == "::1",
444
- _ => false,
445
- })
446
- }
447
-
448
- #[cfg(test)]
449
- mod tests {
450
- use super::*;
451
-
452
- #[test]
453
- fn parses_a_password_with_uri_reserved_characters() {
454
- let config = parse_database_url(
455
- "postgres://postgres:T3st$Pa?#ss*>Word@db.example.com:5432/pizzas",
456
- )
457
- .expect("should parse despite ?/# in the password");
458
- assert_eq!(config.get_hosts().len(), 1);
459
- assert_eq!(config.get_ports(), &[5432]);
460
- assert_eq!(config.get_user(), Some("postgres"));
461
- assert_eq!(config.get_dbname(), Some("pizzas"));
462
- }
463
-
464
- #[test]
465
- fn parses_a_plain_password_too() {
466
- let config = parse_database_url("postgres://postgres:plainpassword@localhost:5432/pizzas")
467
- .expect("should parse a password with no special characters");
468
- assert_eq!(config.get_user(), Some("postgres"));
469
- assert_eq!(config.get_dbname(), Some("pizzas"));
470
- }
471
-
472
- #[test]
473
- fn parses_a_local_url_with_no_user_or_port() {
474
- let config = parse_database_url("postgres://localhost/app_development")
475
- .expect("should parse a peer-auth local URL");
476
- assert_eq!(config.get_dbname(), Some("app_development"));
477
- assert_eq!(config.get_ports(), &[5432]);
478
- assert!(database_url_is_local(&config));
479
- }
5
+ async fn main() -> Result<(), lambda_runtime::Error> {
6
+ rust_host::boot::run().await
480
7
  }
@@ -495,7 +495,7 @@ async fn table_exists<C: GenericClient>(client: &C, name: &str) -> anyhow::Resul
495
495
 
496
496
  /// No backfill: adopting an era means its history already exists elsewhere.
497
497
  /// Runs in its own transaction because `create_head_snapshot`'s SAVEPOINT needs one open.
498
- pub(crate) async fn adopt_head_snapshots<C: GenericClient>(
498
+ pub async fn adopt_head_snapshots<C: GenericClient>(
499
499
  client: &C,
500
500
  domain: &str,
501
501
  aggregates: &[Aggregate],
@@ -300,7 +300,7 @@ impl Tenant {
300
300
  async fn register(&self, slug: &str) -> (u64, Value) {
301
301
  let body = json!({"event_slug": slug, "name": "Ada Lovelace", "email": "ada@example.com"}).to_string();
302
302
  let platform = self.platform_now().await;
303
- let response = web::registrations_route(&body, &platform, &self.client, &self.wasm, &self.config, &NeverInvoker, &crate::ir::fixture_payments()).await;
303
+ let response = web::registrations_route(&body, &platform, &self.client, &self.wasm, &self.config, &NeverInvoker, &crate::commerce_ir::fixture_payments()).await;
304
304
  (response["statusCode"].as_u64().unwrap(), serde_json::from_str(response["body"].as_str().unwrap()).unwrap_or(Value::Null))
305
305
  }
306
306
 
@@ -314,14 +314,14 @@ impl Tenant {
314
314
  let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64;
315
315
  let header = sign(secret, now, &payload);
316
316
  let platform = self.platform_now().await;
317
- let response = web::webhook_route(&payload, &header, &platform, &self.client, &self.wasm, &self.config, &NeverInvoker, &crate::ir::fixture_payments()).await;
317
+ let response = web::webhook_route(&payload, &header, &platform, &self.client, &self.wasm, &self.config, &NeverInvoker, &crate::commerce_ir::fixture_payments()).await;
318
318
  response["statusCode"].as_u64().unwrap()
319
319
  }
320
320
 
321
321
  // POST /registrations/:id/complete with a successful outcome (the mock
322
322
  // walkthrough's "Pay" button).
323
323
  async fn settle(&self, reference: &str) -> u64 {
324
- let response = web::registration_complete_route(reference, r#"{"outcome":"succeeded"}"#, &self.client, &self.wasm, &self.config, &NeverInvoker, &crate::ir::fixture_payments()).await;
324
+ let response = web::registration_complete_route(reference, r#"{"outcome":"succeeded"}"#, &self.client, &self.wasm, &self.config, &NeverInvoker, &crate::commerce_ir::fixture_payments()).await;
325
325
  response["statusCode"].as_u64().unwrap()
326
326
  }
327
327
 
@@ -672,7 +672,7 @@ async fn a_declined_payment_frees_the_seat_and_a_paid_one_keeps_it() {
672
672
 
673
673
  let (_, body) = t.register("declined").await;
674
674
  let first = body["registration_id"].as_str().unwrap().to_string();
675
- let response = web::registration_complete_route(&first, r#"{"outcome":"failed"}"#, &t.client, &t.wasm, &t.config, &NeverInvoker, &crate::ir::fixture_payments()).await;
675
+ let response = web::registration_complete_route(&first, r#"{"outcome":"failed"}"#, &t.client, &t.wasm, &t.config, &NeverInvoker, &crate::commerce_ir::fixture_payments()).await;
676
676
  assert_eq!(response["statusCode"].as_u64(), Some(200));
677
677
  assert_eq!(t.payment_status(&first).await, "failed");
678
678
 
@@ -714,7 +714,7 @@ async fn archiving_a_paid_registration_frees_its_seat_and_restoring_it_takes_the
714
714
  assert_eq!(registration_status(&t, &reference).await, "archived");
715
715
  assert_eq!(t.payment_status(&reference).await, "succeeded", "archiving never touches the Payment");
716
716
  let read = dispatch::read(&t.client, &t.wasm).await.unwrap();
717
- assert_eq!(web::seats_left(&read, "CheckoutFixture", &crate::ir::fixture_payments(), "archive-me"), Some(1));
717
+ assert_eq!(web::seats_left(&read, "CheckoutFixture", &crate::commerce_ir::fixture_payments(), "archive-me"), Some(1));
718
718
 
719
719
  let (status, body) = t.register("archive-me").await;
720
720
  assert_eq!(status, 200, "the archived registration's seat is free again: {body}");
@@ -728,11 +728,11 @@ async fn archiving_a_paid_registration_frees_its_seat_and_restoring_it_takes_the
728
728
  assert_eq!(registration_status(&t, &reference).await, "active");
729
729
  let read = dispatch::read(&t.client, &t.wasm).await.unwrap();
730
730
  assert_eq!(
731
- web::seats_taken(&read, "CheckoutFixture", &crate::ir::fixture_payments(), "archive-me"),
731
+ web::seats_taken(&read, "CheckoutFixture", &crate::commerce_ir::fixture_payments(), "archive-me"),
732
732
  2,
733
733
  "a restored registration counts again, even past capacity: the site refuses a restore with no seat"
734
734
  );
735
- assert_eq!(web::seats_left(&read, "CheckoutFixture", &crate::ir::fixture_payments(), "archive-me"), Some(0));
735
+ assert_eq!(web::seats_left(&read, "CheckoutFixture", &crate::commerce_ir::fixture_payments(), "archive-me"), Some(0));
736
736
  }
737
737
 
738
738
  #[tokio::test]
@@ -876,7 +876,7 @@ async fn an_event_verified_only_by_the_public_mock_secret_is_refused_for_a_real_
876
876
  let payload = event.to_string();
877
877
  let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64;
878
878
  let header = sign(web::MOCK_STRIPE_WEBHOOK_SECRET, now, &payload);
879
- let response = web::webhook_route(&payload, &header, &t.platform, &t.client, &t.wasm, &t.config, &NeverInvoker, &crate::ir::fixture_payments()).await;
879
+ let response = web::webhook_route(&payload, &header, &t.platform, &t.client, &t.wasm, &t.config, &NeverInvoker, &crate::commerce_ir::fixture_payments()).await;
880
880
  assert_eq!(response["statusCode"], 500, "{event}");
881
881
  assert_eq!(t.payment_status(&reference).await, "pending", "a forged completion must not settle a real payment");
882
882
  }
@@ -187,7 +187,7 @@ impl Connection {
187
187
  /// This tenant's connection, read out of the replayed instances, or `None`
188
188
  /// until an Owner has connected an account.
189
189
  pub fn connection(read: &Value, domain: &str) -> Option<Connection> {
190
- let (_, state) = instances_for(read, &crate::ir::payment_connection_binding(domain).instance_prefix()).into_iter().find(|(id, _)| id == CONNECTION_SLUG)?;
190
+ let (_, state) = instances_for(read, &crate::commerce_ir::payment_connection_binding(domain).instance_prefix()).into_iter().find(|(id, _)| id == CONNECTION_SLUG)?;
191
191
  let text = |field: &str| state.get(field).and_then(|v| v.get("value")).and_then(|v| v.as_str()).unwrap_or_default().to_string();
192
192
  Some(Connection {
193
193
  status: state.get("status").and_then(|v| v.as_str()).unwrap_or_default().to_string(),
@@ -236,14 +236,14 @@ pub fn checkout_plan(connection: Option<&Connection>, platform: &PlatformConfig)
236
236
  }
237
237
 
238
238
  async fn dispatch_on_connection(
239
- which: crate::ir::ConnectionVerb,
239
+ which: crate::commerce_ir::ConnectionVerb,
240
240
  facts: Value,
241
241
  client: &Mutex<Client>,
242
242
  wasm_path: &Path,
243
243
  config: &LineageConfig,
244
244
  invoker: &dyn LambdaInvoker,
245
245
  ) -> anyhow::Result<dispatch::Outcome> {
246
- let verb = crate::ir::payment_connection_binding(&config.domain).verb(which).to_string();
246
+ let verb = crate::commerce_ir::payment_connection_binding(&config.domain).verb(which).to_string();
247
247
  dispatch::handle_routed(client, wasm_path, &verb, json!(CONNECTION_SLUG), facts, None, config, invoker).await
248
248
  }
249
249
 
@@ -362,8 +362,8 @@ pub async fn route(
362
362
  "/payments/connection" => show(&caller, platform, client, wasm_path, config).await,
363
363
  "/payments/connection/direct" => direct_route(&caller, raw_body, platform, client, wasm_path, config, invoker).await,
364
364
  "/payments/connection/disconnect" => disconnect_route(&caller, platform, client, wasm_path, config, invoker).await,
365
- "/payments/connection/enable" => switch_route(&caller, crate::ir::ConnectionVerb::Enable, platform, client, wasm_path, config, invoker).await,
366
- _ => switch_route(&caller, crate::ir::ConnectionVerb::Disable, platform, client, wasm_path, config, invoker).await,
365
+ "/payments/connection/enable" => switch_route(&caller, crate::commerce_ir::ConnectionVerb::Enable, platform, client, wasm_path, config, invoker).await,
366
+ _ => switch_route(&caller, crate::commerce_ir::ConnectionVerb::Disable, platform, client, wasm_path, config, invoker).await,
367
367
  })
368
368
  }
369
369
 
@@ -403,10 +403,10 @@ async fn record_connection(
403
403
  None => {
404
404
  let mut creation = facts;
405
405
  creation["slug"] = json!({"value": CONNECTION_SLUG});
406
- dispatch::handle_facts(client, wasm_path, &crate::ir::payment_connection_binding(&config.domain).connect, creation, None, config, invoker).await
406
+ dispatch::handle_facts(client, wasm_path, &crate::commerce_ir::payment_connection_binding(&config.domain).connect, creation, None, config, invoker).await
407
407
  }
408
- Some("disconnected") => dispatch_on_connection(crate::ir::ConnectionVerb::Reconnect, facts, client, wasm_path, config, invoker).await,
409
- Some("paused") => dispatch_on_connection(crate::ir::ConnectionVerb::Resume, facts, client, wasm_path, config, invoker).await,
408
+ Some("disconnected") => dispatch_on_connection(crate::commerce_ir::ConnectionVerb::Reconnect, facts, client, wasm_path, config, invoker).await,
409
+ Some("paused") => dispatch_on_connection(crate::commerce_ir::ConnectionVerb::Resume, facts, client, wasm_path, config, invoker).await,
410
410
  Some(_) => return json_error(409, "an account is already connected — disconnect it first"),
411
411
  };
412
412
  match outcome {
@@ -465,7 +465,7 @@ async fn disconnect_route(caller: &Caller, platform: &PlatformConfig, client: &M
465
465
  return json_error(409, "nothing to disconnect");
466
466
  };
467
467
 
468
- let which = if existing.status == "enabled" { crate::ir::ConnectionVerb::Suspend } else { crate::ir::ConnectionVerb::Disconnect };
468
+ let which = if existing.status == "enabled" { crate::commerce_ir::ConnectionVerb::Suspend } else { crate::commerce_ir::ConnectionVerb::Disconnect };
469
469
  match dispatch_on_connection(which, json!({}), client, wasm_path, config, invoker).await {
470
470
  Ok(outcome) if outcome.accepted => {
471
471
  if existing.is_direct() {
@@ -480,7 +480,7 @@ async fn disconnect_route(caller: &Caller, platform: &PlatformConfig, client: &M
480
480
  }
481
481
 
482
482
  // Enable or disable real payments: operator only.
483
- async fn switch_route(caller: &Caller, which: crate::ir::ConnectionVerb, platform: &PlatformConfig, client: &Mutex<Client>, wasm_path: &Path, config: &LineageConfig, invoker: &dyn LambdaInvoker) -> Value {
483
+ async fn switch_route(caller: &Caller, which: crate::commerce_ir::ConnectionVerb, platform: &PlatformConfig, client: &Mutex<Client>, wasm_path: &Path, config: &LineageConfig, invoker: &dyn LambdaInvoker) -> Value {
484
484
  if let Err(response) = require_operator(caller) {
485
485
  return response;
486
486
  }