hecks 3.12.0 → 3.14.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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/examples/pizzas/bluebook/pizzas.bluebook +4 -4
  3. data/lib/hecks/adapters/driven/script_runner.adapter +3 -0
  4. data/lib/hecks/adapters/driven/script_runner.rb +93 -0
  5. data/lib/hecks/adapters/driven.rb +1 -0
  6. data/lib/hecks/bluebook/dsl/attribute_collector.rb +15 -5
  7. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +16 -12
  8. data/lib/hecks/bluebook/meta_validator.rb +28 -21
  9. data/lib/hecks/bluebook/named_pattern.rb +33 -0
  10. data/lib/hecks/bluebook.rb +1 -0
  11. data/lib/hecks/cli/domain_stub.rb +1 -1
  12. data/lib/hecks/cli/interview_aggregate.rb +4 -4
  13. data/lib/hecks/deploy/bluebook/deploy.bluebook +35 -35
  14. data/lib/hecks/framework/bluebook/change.behaviors +30 -0
  15. data/lib/hecks/framework/bluebook/change.bluebook +162 -0
  16. data/lib/hecks/framework/bluebook/change.hecksagon +21 -0
  17. data/lib/hecks/framework/change.oidc.json +34 -0
  18. data/lib/hecks/framework/console_settings.oidc.json +103 -0
  19. data/lib/hecks/framework/governance.oidc.json +28 -0
  20. data/lib/hecks/framework/identity.oidc.json +18 -0
  21. data/lib/hecks/framework/privacy.oidc.json +30 -0
  22. data/lib/hecks/fuzzing/domain_generator/block_rendering.rb +1 -1
  23. data/lib/hecks/gate/stages.yml +3 -0
  24. data/lib/hecks/grammar/translation.oidc.json +48 -0
  25. data/lib/hecks/hecks/adapters/codebase/test_runner.rb +12 -1
  26. data/lib/hecks/hecks/adapters/deploy_toolchain.rb +2 -1
  27. data/lib/hecks/hecks/codebase.bluebook +37 -37
  28. data/lib/hecks/hecks/custodian.bluebook +14 -14
  29. data/lib/hecks/hecks/hecks.bluebook +2 -2
  30. data/lib/hecks/hecks/rehearsal.bluebook +3 -3
  31. data/lib/hecks/hecks/tooling.bluebook +22 -22
  32. data/lib/hecks/language/bluebook/aggregate.bluebook +1 -0
  33. data/lib/hecks/language/bluebook/command.bluebook +1 -0
  34. data/lib/hecks/language/bluebook/entity.bluebook +1 -0
  35. data/lib/hecks/language/bluebook/query.bluebook +1 -0
  36. data/lib/hecks/language/bluebook/shape.bluebook +8 -7
  37. data/lib/hecks/language/hecksagon/port_operation.bluebook +1 -0
  38. data/lib/hecks/projections/deploy/fargate/cdn/lines.rb +12 -1
  39. data/lib/hecks/projections/site/admin_cms/templates/membership.rb +10 -7
  40. data/lib/hecks/projections/site/edge/behaviours.rb +10 -6
  41. data/lib/hecks/projections/site/edge/checks.rb +2 -0
  42. data/lib/hecks/projections/site/edge/function_checks.rb +104 -0
  43. data/lib/hecks/projections/site/edge/function_code.rb +86 -0
  44. data/lib/hecks/projections/site/edge/redirect_host.js.tmpl +65 -0
  45. data/lib/hecks/projections/site/edge.rb +35 -9
  46. data/lib/hecks/projections/site/site_cdn.rb +21 -7
  47. data/lib/hecks/projections/site/table/row_builder.rb +12 -2
  48. data/lib/hecks/projections/site/table.rb +4 -2
  49. data/lib/hecks/quality_control/quality_control.bluebook +51 -51
  50. data/lib/hecks/site/bluebook/site.bluebook +21 -6
  51. data/lib/hecks/sme/bluebook/sme.bluebook +12 -12
  52. data/lib/hecks/tickets/bluebook/tickets.bluebook +3 -3
  53. data/lib/hecks/tools/oidc_manifests.rb +44 -6
  54. data/lib/hecks/tools/site_routes/templates.rb +13 -9
  55. data/lib/hecks/version.rb +1 -1
  56. data/prebuilt/{d74a00e996222c1d8b676d5959092effc46b4dc59e0555f3bf9554ac58e3094a.marshal → 1a6f596be124228be93eba1d86dbc9d2933190fa389b284331241cb9f27115a0.marshal} +0 -0
  57. data/prebuilt/{9ee54e2f0d4d6d3fe046827779b221e02de665fba911a048ae5dd8b8fbfb7d90.marshal → a883a7e568667aa5ac904c82daff356bdf9e996252a36af6def4a4a43efb967e.marshal} +0 -0
  58. data/prebuilt/verdicts-872993b1aa26d73fe48f28251374b8585fed145d02aab42be0f00de386b6ff80.json +1 -0
  59. data/rust/host/HECKS_RELEASE +1 -1
  60. data/rust/host/src/auth.rs +58 -0
  61. data/rust/host/src/payments/roles.rs +159 -0
  62. data/rust/host/src/payments/tests.rs +25 -0
  63. data/rust/host/src/payments.rs +20 -11
  64. data/rust/host/src/platform_signin/session.rs +1 -1
  65. data/rust/parser/src/build/mod.rs +1 -0
  66. data/rust/parser/src/build/named_pattern.rs +23 -0
  67. data/rust/parser/src/keywords.rs +6 -0
  68. data/rust/parser/src/parse/mod.rs +22 -1
  69. metadata +24 -7
  70. data/prebuilt/verdicts-df19f35868d3284a70f55e42dcef87ef4e65e6a678e5135f72e1f6cff4c1357b.json +0 -1
  71. /data/lib/hecks/framework/{oidc.json → compliance.oidc.json} +0 -0
  72. /data/lib/hecks/grammar/{oidc.json → expression.oidc.json} +0 -0
@@ -1 +1 @@
1
- 3.12.0
1
+ 3.14.0
@@ -260,6 +260,43 @@ fn identity_id_from_state(state: &Value) -> Option<String> {
260
260
  })
261
261
  }
262
262
 
263
+ /// Resolves an external identity the way the Google callback does: the PostgresEra head first,
264
+ /// then the flat journal's `instances`. A domain whose Identity chapter keeps no head of its own
265
+ /// (its heads cover its own aggregates and the membership aggregate) holds the link only there.
266
+ ///
267
+ /// # Errors
268
+ ///
269
+ /// Returns the flat journal's read error, except that a missing flat journal means no link there. A
270
+ /// head that cannot be read is logged and skipped, as the Google callback does, so the flat journal
271
+ /// still gets its turn.
272
+ pub async fn resolve_identity_anywhere(
273
+ client: &Mutex<Client>,
274
+ wasm_path: &Path,
275
+ domain_ir: &Value,
276
+ issuer: &str,
277
+ subject: &str,
278
+ ) -> anyhow::Result<Option<String>> {
279
+ match resolve_identity_from_head(client, domain_ir, issuer, subject).await {
280
+ Ok(Some(found)) => return Ok(Some(found)),
281
+ Ok(None) => {}
282
+ Err(error) => eprintln!("resolve_identity: identity head: {error:#}"),
283
+ }
284
+ let read = match dispatch::read(client, wasm_path).await {
285
+ Ok(read) => read,
286
+ Err(error) if flat_journal_is_absent(&error) => return Ok(None),
287
+ Err(error) => return Err(error),
288
+ };
289
+ Ok(resolve_identity(read.get("instances").unwrap_or(&Value::Null), issuer, subject))
290
+ }
291
+
292
+ // A store with heads only has no flat journal to read; that is "no link there", not an outage.
293
+ fn flat_journal_is_absent(error: &anyhow::Error) -> bool {
294
+ error
295
+ .chain()
296
+ .filter_map(|cause| cause.downcast_ref::<tokio_postgres::Error>())
297
+ .any(|db| db.code() == Some(&tokio_postgres::error::SqlState::UNDEFINED_TABLE))
298
+ }
299
+
263
300
  /// Identity lives in the PostgresEra head, not `dispatch::read`'s
264
301
  /// `instances` — tries both the domain-qualified and the bare relation name.
265
302
  pub async fn resolve_identity_from_head(
@@ -1102,6 +1139,27 @@ mod tests {
1102
1139
  assert!(resolve_identity(&instances, "google", "nope").is_none());
1103
1140
  }
1104
1141
 
1142
+ #[test]
1143
+ fn resolve_identity_finds_a_link_that_only_the_flat_journal_holds() {
1144
+ // The shape `ExternalIdentifier.Link` leaves in the flat snapshot of a domain whose Identity
1145
+ // chapter has no head of its own; the key carries the issuer URL and the subject.
1146
+ let instances = json!({
1147
+ "Identity::ExternalIdentifier#https://accounts.google.com:111": {
1148
+ "identity": "471ef402-8609-4fa4-9288-6372fc74650d",
1149
+ "key": {"value": "https://accounts.google.com:111"},
1150
+ "issuer": {"value": "https://accounts.google.com"},
1151
+ "subject": {"value": "111"},
1152
+ },
1153
+ });
1154
+ let read = json!({ "instances": instances });
1155
+ assert_eq!(
1156
+ resolve_identity(read.get("instances").unwrap_or(&Value::Null), "https://accounts.google.com", "111").as_deref(),
1157
+ Some("471ef402-8609-4fa4-9288-6372fc74650d")
1158
+ );
1159
+ assert!(resolve_identity(read.get("instances").unwrap_or(&Value::Null), "https://accounts.google.com", "112").is_none());
1160
+ assert!(resolve_identity(&Value::Null, "https://accounts.google.com", "111").is_none());
1161
+ }
1162
+
1105
1163
  #[test]
1106
1164
  fn resolve_identity_reads_the_2_0_identity_field() {
1107
1165
  let instances = json!({
@@ -0,0 +1,159 @@
1
+ //! Which signed-in roles may manage a site's payments: a per-site setting, read once at boot.
2
+ //!
3
+ //! `HECKS_PAYMENTS_MANAGER_ROLES` is a comma-separated list of exact role names. Unset means
4
+ //! `Owner`, which is what the host did before the setting existed. A set value must be a
5
+ //! plausible list or the boot is refused; a refusal names the variable and the kind of mistake,
6
+ //! never the value. A caller manages payments when their role is in the list, compared exactly:
7
+ //! no case folding, no hierarchy, no wildcard.
8
+
9
+ use std::sync::OnceLock;
10
+
11
+ /// The variable that names the roles allowed to manage payments.
12
+ pub const VARIABLE: &str = "HECKS_PAYMENTS_MANAGER_ROLES";
13
+ /// The role that manages payments when the variable is unset.
14
+ pub const DEFAULT_ROLE: &str = "Owner";
15
+ /// The most roles one list may name.
16
+ pub const MAX_ENTRIES: usize = 8;
17
+ /// The longest role name, after trimming.
18
+ pub const MAX_ENTRY_LENGTH: usize = 32;
19
+
20
+ /// The roles that manage payments when nothing is configured.
21
+ pub fn default_roles() -> Vec<String> {
22
+ vec![DEFAULT_ROLE.to_string()]
23
+ }
24
+
25
+ /// Parses the variable's value; `None` (unset) gives the default list.
26
+ pub fn parse(raw: Option<&str>) -> Result<Vec<String>, String> {
27
+ let Some(raw) = raw else { return Ok(default_roles()) };
28
+ let refuse = |why: &str| Err(format!("{VARIABLE} is invalid: {why}"));
29
+ let entries: Vec<&str> = raw.split(',').map(str::trim).collect();
30
+ if entries.len() > MAX_ENTRIES {
31
+ return refuse(&format!("it names more than {MAX_ENTRIES} roles"));
32
+ }
33
+ for (index, entry) in entries.iter().enumerate() {
34
+ let position = index + 1;
35
+ if entry.is_empty() {
36
+ return refuse(&format!("entry {position} is empty; name at least one role and no blank entries"));
37
+ }
38
+ if entry.len() > MAX_ENTRY_LENGTH {
39
+ return refuse(&format!("entry {position} is longer than {MAX_ENTRY_LENGTH} characters"));
40
+ }
41
+ let starts_well = entry.starts_with(|c: char| c.is_ascii_alphanumeric());
42
+ if !starts_well || !entry.chars().all(|c| c.is_ascii_alphanumeric() || c == ' ' || c == '-') {
43
+ return refuse(&format!("entry {position} is not a role name: letters, digits, spaces and hyphens only, starting with a letter or digit"));
44
+ }
45
+ }
46
+ Ok(entries.into_iter().map(String::from).collect())
47
+ }
48
+
49
+ /// Reads the setting into `cell` the first time and never again, whatever the environment
50
+ /// does afterwards. `read` stands for the environment lookup.
51
+ pub fn read_once(cell: &OnceLock<Result<Vec<String>, String>>, read: impl FnOnce() -> Option<String>) -> &Result<Vec<String>, String> {
52
+ cell.get_or_init(|| parse(read().as_deref()))
53
+ }
54
+
55
+ /// This process's setting, read from the environment on first use (the boot check is the first).
56
+ pub fn configured() -> &'static Result<Vec<String>, String> {
57
+ static CELL: OnceLock<Result<Vec<String>, String>> = OnceLock::new();
58
+ read_once(&CELL, || std::env::var(VARIABLE).ok())
59
+ }
60
+
61
+ /// Refuses the boot when this process's setting is invalid.
62
+ pub fn check_boot() -> Result<(), String> {
63
+ configured().as_ref().map(|_| ()).map_err(Clone::clone)
64
+ }
65
+
66
+ /// The roles for a request. An invalid setting never gets here (the boot was refused); should it
67
+ /// ever, only the default role manages, so a mistake narrows access instead of widening it.
68
+ pub fn manager_roles() -> Vec<String> {
69
+ configured().clone().unwrap_or_else(|_| default_roles())
70
+ }
71
+
72
+ /// Whether `role` is one of `roles`, by exact string match.
73
+ pub fn manages(roles: &[String], role: &str) -> bool {
74
+ roles.iter().any(|allowed| allowed == role)
75
+ }
76
+
77
+ #[cfg(test)]
78
+ mod tests {
79
+ use super::*;
80
+
81
+ fn roles(raw: Option<&str>) -> Vec<String> {
82
+ parse(raw).unwrap()
83
+ }
84
+
85
+ #[test]
86
+ fn unset_means_owner_only() {
87
+ let list = roles(None);
88
+ assert_eq!(list, ["Owner"]);
89
+ assert!(manages(&list, "Owner"));
90
+ assert!(!manages(&list, "Admin"));
91
+ assert!(!manages(&list, "Member"));
92
+ }
93
+
94
+ #[test]
95
+ fn a_configured_list_admits_exactly_its_roles() {
96
+ let list = roles(Some("Owner,Admin"));
97
+ assert!(manages(&list, "Owner") && manages(&list, "Admin"));
98
+ assert!(!manages(&list, "Editor"));
99
+ assert!(!manages(&list, ""));
100
+ }
101
+
102
+ #[test]
103
+ fn whitespace_is_trimmed_and_case_is_exact() {
104
+ let list = roles(Some(" Owner , Account Admin "));
105
+ assert_eq!(list, ["Owner", "Account Admin"]);
106
+ let lower = roles(Some("admin"));
107
+ assert!(manages(&lower, "admin"));
108
+ assert!(!manages(&lower, "Admin"), "no case folding");
109
+ assert!(!manages(&roles(Some("Admin")), "admin"));
110
+ }
111
+
112
+ #[test]
113
+ fn no_wildcard_and_no_hierarchy() {
114
+ assert!(parse(Some("*")).is_err());
115
+ assert!(!manages(&roles(Some("Admin")), "Owner"), "naming Admin does not admit Owner");
116
+ }
117
+
118
+ #[test]
119
+ fn an_invalid_setting_is_refused_naming_the_variable() {
120
+ let too_many = ["Role"; MAX_ENTRIES + 1].join(",");
121
+ let too_long = "A".repeat(MAX_ENTRY_LENGTH + 1);
122
+ for bad in ["", " ", ",", "Owner,", ",Owner", "Owner,,Admin", "Own/er", "Owner\nAdmin", "Own\u{7}er", "Admin;Owner", "-Admin", "Ad_min", "Régisseur", too_many.as_str(), too_long.as_str()] {
123
+ let message = parse(Some(bad)).expect_err(bad);
124
+ assert!(message.starts_with(VARIABLE), "{message}");
125
+ }
126
+ assert!(parse(Some(&"A".repeat(MAX_ENTRY_LENGTH))).is_ok());
127
+ assert!(parse(Some(&["Role"; MAX_ENTRIES].join(","))).is_ok());
128
+ }
129
+
130
+ #[test]
131
+ fn a_refusal_never_echoes_the_value() {
132
+ let message = parse(Some("sk_live_SECRET/value")).unwrap_err();
133
+ assert!(!message.contains("sk_live"), "{message}");
134
+ }
135
+
136
+ #[test]
137
+ fn the_setting_is_read_once() {
138
+ let cell = OnceLock::new();
139
+ let reads = std::cell::Cell::new(0);
140
+ let first = read_once(&cell, || {
141
+ reads.set(reads.get() + 1);
142
+ Some("Owner,Admin".to_string())
143
+ });
144
+ assert_eq!(first.as_ref().unwrap(), &["Owner", "Admin"]);
145
+ let second = read_once(&cell, || {
146
+ reads.set(reads.get() + 1);
147
+ Some("Editor".to_string())
148
+ });
149
+ assert_eq!(second.as_ref().unwrap(), &["Owner", "Admin"]);
150
+ assert_eq!(reads.get(), 1);
151
+ }
152
+
153
+ #[test]
154
+ fn an_invalid_value_stays_refused_once_read() {
155
+ let cell = OnceLock::new();
156
+ assert!(read_once(&cell, || Some("a/b".to_string())).is_err());
157
+ assert!(read_once(&cell, || None).is_err());
158
+ }
159
+ }
@@ -197,6 +197,7 @@ async fn tenant(name: &str) -> Tenant {
197
197
  direct_live_publishable_key: String::new(),
198
198
  webhook_secret: Some(WEBHOOK_SECRET.to_string()),
199
199
  operators: vec![OPERATOR.to_string()],
200
+ manager_roles: roles::default_roles(),
200
201
  };
201
202
  Tenant {
202
203
  client,
@@ -412,6 +413,30 @@ async fn only_an_owner_may_connect_or_disconnect_and_only_the_operator_may_enabl
412
413
  assert_eq!(t.status().await, "enabled");
413
414
  }
414
415
 
416
+ #[tokio::test]
417
+ async fn a_site_that_lists_admin_as_a_payments_manager_lets_an_admin_manage() {
418
+ let mut t = tenant("hecks_pay_test_manager_roles").await;
419
+ t.platform.manager_roles = vec!["Owner".to_string(), "Admin".to_string()];
420
+
421
+ let (status, body) = t.call("GET", "/payments/connection", json!({}), Some(ADMIN)).await;
422
+ assert_eq!((status, body["can_manage"].clone()), (200, json!(true)), "{body}");
423
+ t.connect().await;
424
+ assert_eq!(t.status().await, "connected");
425
+ let (status, _) = t.call("POST", "/payments/connection/disconnect", json!({}), Some(ADMIN)).await;
426
+ assert_eq!(status, 200);
427
+ assert_eq!(t.status().await, "disconnected");
428
+
429
+ // Enabling stays with the operator, and the Owner still manages.
430
+ let (status, body) = t.call("GET", "/payments/connection", json!({}), Some(OWNER)).await;
431
+ assert_eq!((status, body["can_manage"].clone()), (200, json!(true)), "{body}");
432
+ // Someone signed out, or not listed, is refused with today's message.
433
+ let (status, _) = t.call("GET", "/payments/connection", json!({}), None).await;
434
+ assert_eq!(status, 401);
435
+ t.platform.manager_roles = vec!["Editor".to_string()];
436
+ let (status, body) = t.call("POST", "/payments/connection/direct", json!({"mode": "test"}), Some(OWNER)).await;
437
+ assert_eq!((status, body["error"].as_str()), (403, Some("only an Owner can manage payments")));
438
+ }
439
+
415
440
  #[tokio::test]
416
441
  async fn an_admin_granted_owner_passes_the_payments_gate_and_keeps_the_admin_gate() {
417
442
  let t = tenant("hecks_pay_test_owner_bootstrap").await;
@@ -15,6 +15,8 @@ use tokio::sync::Mutex;
15
15
  use tokio_postgres::Client;
16
16
 
17
17
  mod keystore;
18
+ /// Which member roles may manage payments (`HECKS_PAYMENTS_MANAGER_ROLES`).
19
+ pub mod roles;
18
20
  use keystore::{KeyStore, StoredDocument, StoredKeys};
19
21
 
20
22
  const CONNECTION_SLUG: &str = "payments";
@@ -63,11 +65,16 @@ pub struct PlatformConfig {
63
65
  pub webhook_secret: Option<String>,
64
66
  /// Lower-cased emails allowed to enable or disable real payments.
65
67
  pub operators: Vec<String>,
68
+ /// The exact role names that may manage payments (`HECKS_PAYMENTS_MANAGER_ROLES`,
69
+ /// default `Owner`), read once at boot; see `roles`.
70
+ pub manager_roles: Vec<String>,
66
71
  }
67
72
 
68
- /// Refuses the boot on AWS when checkout is enabled but `PAYMENTS_ACCOUNT_SECRET_ID`
69
- /// names no secret; off AWS, or with checkout off, the host boots as usual.
73
+ /// Refuses the boot when `HECKS_PAYMENTS_MANAGER_ROLES` is invalid, and on AWS when
74
+ /// checkout is enabled but `PAYMENTS_ACCOUNT_SECRET_ID` names no secret; off AWS, or with
75
+ /// checkout off, the host boots as usual.
70
76
  pub fn check_boot(checkout_enabled: bool) -> Result<(), String> {
77
+ roles::check_boot()?;
71
78
  keystore::check_boot(checkout_enabled)
72
79
  }
73
80
 
@@ -102,6 +109,7 @@ impl PlatformConfig {
102
109
  .map(|email| email.trim().to_lowercase())
103
110
  .filter(|email| !email.is_empty())
104
111
  .collect(),
112
+ manager_roles: roles::manager_roles(),
105
113
  }
106
114
  }
107
115
 
@@ -276,7 +284,7 @@ fn refusal_message(refusal: &Value) -> String {
276
284
  }
277
285
 
278
286
  struct Caller {
279
- owner: bool,
287
+ manager: bool,
280
288
  operator: bool,
281
289
  }
282
290
 
@@ -288,14 +296,14 @@ async fn caller(domain_ir: &Value, cookies: &HashMap<String, String>, secret: &s
288
296
  return Err(not_logged_in());
289
297
  };
290
298
  match auth::active_role(client, domain_ir, &email).await {
291
- Ok(Some(role)) => Ok(Caller { owner: role == "Owner", operator: platform.is_operator(&email) }),
299
+ Ok(Some(role)) => Ok(Caller { manager: roles::manages(&platform.manager_roles, &role), operator: platform.is_operator(&email) }),
292
300
  Ok(None) => Err(not_logged_in()),
293
301
  Err(e) => Err(json_error(500, &format!("members lookup failed: {e}"))),
294
302
  }
295
303
  }
296
304
 
297
- fn require_owner(caller: &Caller) -> Result<(), Value> {
298
- if caller.owner { Ok(()) } else { Err(json_error(403, "only an Owner can manage payments")) }
305
+ fn require_manager(caller: &Caller) -> Result<(), Value> {
306
+ if caller.manager { Ok(()) } else { Err(json_error(403, "only an Owner can manage payments")) }
299
307
  }
300
308
 
301
309
  fn require_operator(caller: &Caller) -> Result<(), Value> {
@@ -322,7 +330,7 @@ fn connection_json(connection: Option<&Connection>, platform: &PlatformConfig, c
322
330
  "direct_modes": platform.direct_modes(),
323
331
  "direct": connection.is_some_and(Connection::is_direct),
324
332
  "can_save_keys": platform.can_save_keys(),
325
- "can_manage": caller.owner,
333
+ "can_manage": caller.manager,
326
334
  "can_enable": caller.operator,
327
335
  })
328
336
  }
@@ -375,9 +383,9 @@ pub async fn route(
375
383
  })
376
384
  }
377
385
 
378
- // GET: readable by an Owner or the operator, nobody else.
386
+ // GET: readable by a payments manager or the operator, nobody else.
379
387
  async fn show(caller: &Caller, platform: &PlatformConfig, client: &Mutex<Client>, wasm_path: &Path, config: &LineageConfig) -> Value {
380
- if !caller.owner && !caller.operator {
388
+ if !caller.manager && !caller.operator {
381
389
  return json_error(403, "not permitted");
382
390
  }
383
391
  connection_response(caller, platform, client, wasm_path, config).await
@@ -429,7 +437,7 @@ async fn record_connection(
429
437
  // name), and the key itself is never stored or logged.
430
438
  #[allow(clippy::too_many_arguments)]
431
439
  async fn direct_route(caller: &Caller, raw_body: &str, platform: &PlatformConfig, client: &Mutex<Client>, wasm_path: &Path, config: &LineageConfig, invoker: &dyn LambdaInvoker) -> Value {
432
- if let Err(response) = require_owner(caller) {
440
+ if let Err(response) = require_manager(caller) {
433
441
  return response;
434
442
  }
435
443
  let body = match parse_body(raw_body) {
@@ -462,7 +470,7 @@ async fn direct_route(caller: &Caller, raw_body: &str, platform: &PlatformConfig
462
470
  // guests have paid through this account. Disconnecting the business's own account
463
471
  // also removes its webhook and deletes the saved keys.
464
472
  async fn disconnect_route(caller: &Caller, platform: &PlatformConfig, client: &Mutex<Client>, wasm_path: &Path, config: &LineageConfig, invoker: &dyn LambdaInvoker) -> Value {
465
- if let Err(response) = require_owner(caller) {
473
+ if let Err(response) = require_manager(caller) {
466
474
  return response;
467
475
  }
468
476
  let existing = match current_connection(client, wasm_path, config).await {
@@ -762,6 +770,7 @@ pub(crate) fn test_platform() -> PlatformConfig {
762
770
  direct_live_publishable_key: String::new(),
763
771
  webhook_secret: None,
764
772
  operators: Vec::new(),
773
+ manager_roles: roles::default_roles(),
765
774
  }
766
775
  }
767
776
 
@@ -123,7 +123,7 @@ pub struct HostMembers<'a> {
123
123
  #[async_trait]
124
124
  impl MemberDirectory for HostMembers<'_> {
125
125
  async fn identity_for(&self, issuer: &str, subject: &str) -> anyhow::Result<Option<String>> {
126
- auth::resolve_identity_from_head(self.client, self.domain_ir, issuer, subject).await
126
+ auth::resolve_identity_anywhere(self.client, self.wasm_path, self.domain_ir, issuer, subject).await
127
127
  }
128
128
 
129
129
  async fn session_for_identity(&self, identity_id: &str) -> anyhow::Result<Option<Session>> {
@@ -4,6 +4,7 @@
4
4
  pub mod closed_sets;
5
5
  pub mod identity;
6
6
  pub mod naming;
7
+ pub mod named_pattern;
7
8
  pub mod pattern_subset;
8
9
  pub mod query_derive;
9
10
  pub mod query_inference;
@@ -0,0 +1,23 @@
1
+ //! The patterns a bluebook names instead of spelling: `matches: :snake_name` stands for exactly
2
+ //! its regex. Mirrors `Hecks::Bluebook::NamedPattern`; spec/named_pattern_spec.rb holds the two
3
+ //! tables to each other.
4
+
5
+ /// Name => the regex source it stands for.
6
+ const TABLE: &[(&str, &str)] = &[
7
+ ("present", r"[^ \t\n\r]"),
8
+ ("not_empty", r"[^\t\n\r]"),
9
+ ("no_whitespace", r"^[^ \t\n\r]+$"),
10
+ ("snake_name", r"^[a-z][a-z0-9_]*$"),
11
+ ("git_sha", r"^[0-9a-fA-F]{7,40}$"),
12
+ ("digits", r"^[0-9]+$"),
13
+ ];
14
+
15
+ /// The regex source `name` stands for, or `None` when the language gives no such name.
16
+ pub fn source(name: &str) -> Option<&'static str> {
17
+ TABLE.iter().find(|(given, _)| *given == name).map(|(_, source)| *source)
18
+ }
19
+
20
+ /// Every name the language gives, in table order.
21
+ pub fn names() -> Vec<&'static str> {
22
+ TABLE.iter().map(|(name, _)| *name).collect()
23
+ }
@@ -256,6 +256,7 @@ pub static ARGUMENTS: &[ArgumentRow] = &[
256
256
  ArgumentRow { keyword: "attribute", context: "Aggregate", at: "", named: "default", kind: "literal", required: "false", fills: "default", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
257
257
  ArgumentRow { keyword: "attribute", context: "Aggregate", at: "", named: "optional", kind: "flag", required: "false", fills: "optional", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
258
258
  ArgumentRow { keyword: "attribute", context: "Aggregate", at: "", named: "pattern", kind: "text", required: "false", fills: "pattern", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
259
+ ArgumentRow { keyword: "attribute", context: "Aggregate", at: "", named: "matches", kind: "symbol", required: "false", fills: "matches", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
259
260
  ArgumentRow { keyword: "attribute", context: "Aggregate", at: "", named: "admits", kind: "text", required: "false", fills: "admits", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
260
261
  ArgumentRow { keyword: "attribute", context: "Aggregate", at: "", named: "one_of", kind: "list", required: "false", fills: "one_of", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
261
262
  ArgumentRow { keyword: "invariant", context: "Aggregate", at: "1", named: "", kind: "text", required: "true", fills: "description", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
@@ -356,6 +357,7 @@ pub static ARGUMENTS: &[ArgumentRow] = &[
356
357
  ArgumentRow { keyword: "attribute", context: "Command", at: "", named: "default", kind: "literal", required: "false", fills: "default", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
357
358
  ArgumentRow { keyword: "attribute", context: "Command", at: "", named: "optional", kind: "flag", required: "false", fills: "optional", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
358
359
  ArgumentRow { keyword: "attribute", context: "Command", at: "", named: "pattern", kind: "text", required: "false", fills: "pattern", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
360
+ ArgumentRow { keyword: "attribute", context: "Command", at: "", named: "matches", kind: "symbol", required: "false", fills: "matches", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
359
361
  ArgumentRow { keyword: "attribute", context: "Command", at: "", named: "admits", kind: "text", required: "false", fills: "admits", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
360
362
  ArgumentRow { keyword: "attribute", context: "Command", at: "", named: "one_of", kind: "list", required: "false", fills: "one_of", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
361
363
  ArgumentRow { keyword: "corrects", context: "Command", at: "1", named: "", kind: "text", required: "true", fills: "target", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
@@ -380,6 +382,7 @@ pub static ARGUMENTS: &[ArgumentRow] = &[
380
382
  ArgumentRow { keyword: "attribute", context: "Entity", at: "", named: "default", kind: "literal", required: "false", fills: "default", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
381
383
  ArgumentRow { keyword: "attribute", context: "Entity", at: "", named: "optional", kind: "flag", required: "false", fills: "optional", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
382
384
  ArgumentRow { keyword: "attribute", context: "Entity", at: "", named: "pattern", kind: "text", required: "false", fills: "pattern", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
385
+ ArgumentRow { keyword: "attribute", context: "Entity", at: "", named: "matches", kind: "symbol", required: "false", fills: "matches", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
383
386
  ArgumentRow { keyword: "attribute", context: "Entity", at: "", named: "admits", kind: "text", required: "false", fills: "admits", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
384
387
  ArgumentRow { keyword: "attribute", context: "Entity", at: "", named: "one_of", kind: "list", required: "false", fills: "one_of", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
385
388
  ArgumentRow { keyword: "reference_to", context: "Entity", at: "1", named: "", kind: "constant", required: "true", fills: "type", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
@@ -446,6 +449,7 @@ pub static ARGUMENTS: &[ArgumentRow] = &[
446
449
  ArgumentRow { keyword: "attribute", context: "Query", at: "", named: "default", kind: "literal", required: "false", fills: "default", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
447
450
  ArgumentRow { keyword: "attribute", context: "Query", at: "", named: "optional", kind: "flag", required: "false", fills: "optional", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
448
451
  ArgumentRow { keyword: "attribute", context: "Query", at: "", named: "pattern", kind: "text", required: "false", fills: "pattern", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
452
+ ArgumentRow { keyword: "attribute", context: "Query", at: "", named: "matches", kind: "symbol", required: "false", fills: "matches", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
449
453
  ArgumentRow { keyword: "attribute", context: "Query", at: "", named: "admits", kind: "text", required: "false", fills: "admits", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
450
454
  ArgumentRow { keyword: "attribute", context: "Query", at: "", named: "one_of", kind: "list", required: "false", fills: "one_of", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
451
455
  ArgumentRow { keyword: "reference_to", context: "Query", at: "1", named: "", kind: "constant", required: "true", fills: "type", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
@@ -464,6 +468,7 @@ pub static ARGUMENTS: &[ArgumentRow] = &[
464
468
  ArgumentRow { keyword: "attribute", context: "ValueObject", at: "", named: "default", kind: "literal", required: "false", fills: "default", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
465
469
  ArgumentRow { keyword: "attribute", context: "ValueObject", at: "", named: "optional", kind: "flag", required: "false", fills: "optional", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
466
470
  ArgumentRow { keyword: "attribute", context: "ValueObject", at: "", named: "pattern", kind: "text", required: "false", fills: "pattern", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
471
+ ArgumentRow { keyword: "attribute", context: "ValueObject", at: "", named: "matches", kind: "symbol", required: "false", fills: "matches", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
467
472
  ArgumentRow { keyword: "attribute", context: "ValueObject", at: "", named: "admits", kind: "text", required: "false", fills: "admits", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
468
473
  ArgumentRow { keyword: "attribute", context: "ValueObject", at: "", named: "one_of", kind: "list", required: "false", fills: "one_of", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
469
474
  ArgumentRow { keyword: "invariant", context: "ValueObject", at: "1", named: "", kind: "text", required: "true", fills: "description", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
@@ -500,6 +505,7 @@ pub static ARGUMENTS: &[ArgumentRow] = &[
500
505
  ArgumentRow { keyword: "attribute", context: "PortOperation", at: "", named: "default", kind: "literal", required: "false", fills: "default", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
501
506
  ArgumentRow { keyword: "attribute", context: "PortOperation", at: "", named: "optional", kind: "flag", required: "false", fills: "optional", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
502
507
  ArgumentRow { keyword: "attribute", context: "PortOperation", at: "", named: "pattern", kind: "text", required: "false", fills: "pattern", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
508
+ ArgumentRow { keyword: "attribute", context: "PortOperation", at: "", named: "matches", kind: "symbol", required: "false", fills: "matches", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
503
509
  ArgumentRow { keyword: "attribute", context: "PortOperation", at: "", named: "admits", kind: "text", required: "false", fills: "admits", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
504
510
  ArgumentRow { keyword: "attribute", context: "PortOperation", at: "", named: "one_of", kind: "list", required: "false", fills: "one_of", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
505
511
  ArgumentRow { keyword: "emits", context: "PortOperation", at: "1", named: "", kind: "text", required: "true", fills: "emits", selects: "", pair_key_fills: "", pair_value_fills: "", pairs_shape: "", status: "admitted", variadic: "", minimum: "" },
@@ -1134,7 +1134,28 @@ pub(crate) fn build_attribute(
1134
1134
  let (type_name, list, closed_set) = resolve_type_expression(file, line, word, &name, text)?;
1135
1135
  let default = named_raw(args, "default").map(ruby_value::read);
1136
1136
  let optional = named_flag(args, "optional");
1137
- let pattern = named_text(args, "pattern");
1137
+ // `matches: :name` is the pattern the language gives that name.
1138
+ let pattern = match named_symbol(args, "matches") {
1139
+ Some(given) => {
1140
+ if named_text(args, "pattern").is_some() {
1141
+ return Err(Diagnostic::new(file, line, "matches: and pattern: both state the pattern".to_string()));
1142
+ }
1143
+ match crate::build::named_pattern::source(&given) {
1144
+ Some(source) => Some(source.to_string()),
1145
+ None => {
1146
+ return Err(Diagnostic::new(
1147
+ file,
1148
+ line,
1149
+ format!(
1150
+ "matches: {given:?} is not a named pattern — the names are {}",
1151
+ crate::build::named_pattern::names().join(", ")
1152
+ ),
1153
+ ))
1154
+ }
1155
+ }
1156
+ }
1157
+ None => named_text(args, "pattern"),
1158
+ };
1138
1159
  let admits = named_text(args, "admits");
1139
1160
  // Fail closed at declaration time on a pattern outside the shared subset, as Ruby does.
1140
1161
  if let Some(pat) = &pattern {
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hecks
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.12.0
4
+ version: 3.14.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chris Young
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-10-16 00:00:00.000000000 Z
11
+ date: 2026-10-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: prism
@@ -97,6 +97,8 @@ files:
97
97
  - lib/hecks/adapters/driven/postgres_era.adapter
98
98
  - lib/hecks/adapters/driven/prism.adapter
99
99
  - lib/hecks/adapters/driven/prism.rb
100
+ - lib/hecks/adapters/driven/script_runner.adapter
101
+ - lib/hecks/adapters/driven/script_runner.rb
100
102
  - lib/hecks/adapters/driven/secure_random_identity.adapter
101
103
  - lib/hecks/adapters/driven/secure_random_identity.rb
102
104
  - lib/hecks/adapters/driven/sql_query_builder.rb
@@ -320,6 +322,7 @@ files:
320
322
  - lib/hecks/bluebook/model_check/reference_checks.rb
321
323
  - lib/hecks/bluebook/model_check/saga_checks.rb
322
324
  - lib/hecks/bluebook/model_check/verbs.rb
325
+ - lib/hecks/bluebook/named_pattern.rb
323
326
  - lib/hecks/bluebook/pattern_subset.rb
324
327
  - lib/hecks/bluebook/pattern_subset/refusals.rb
325
328
  - lib/hecks/bluebook/pattern_subset/scan.rb
@@ -454,13 +457,21 @@ files:
454
457
  - lib/hecks/forms/value_object_shape.rb
455
458
  - lib/hecks/fqn.rb
456
459
  - lib/hecks/framework.rb
460
+ - lib/hecks/framework/bluebook/change.behaviors
461
+ - lib/hecks/framework/bluebook/change.bluebook
462
+ - lib/hecks/framework/bluebook/change.hecksagon
457
463
  - lib/hecks/framework/bluebook/compliance.bluebook
458
464
  - lib/hecks/framework/bluebook/console_settings.bluebook
459
465
  - lib/hecks/framework/bluebook/framework.hecksagon
460
466
  - lib/hecks/framework/bluebook/governance.bluebook
461
467
  - lib/hecks/framework/bluebook/identity.bluebook
462
468
  - lib/hecks/framework/bluebook/privacy.bluebook
463
- - lib/hecks/framework/oidc.json
469
+ - lib/hecks/framework/change.oidc.json
470
+ - lib/hecks/framework/compliance.oidc.json
471
+ - lib/hecks/framework/console_settings.oidc.json
472
+ - lib/hecks/framework/governance.oidc.json
473
+ - lib/hecks/framework/identity.oidc.json
474
+ - lib/hecks/framework/privacy.oidc.json
464
475
  - lib/hecks/freezer.rb
465
476
  - lib/hecks/fuzzing.rb
466
477
  - lib/hecks/fuzzing/bounded_exhaustive_expressions.rb
@@ -574,11 +585,12 @@ files:
574
585
  - lib/hecks/grammar/evolve/keywords.rb
575
586
  - lib/hecks/grammar/evolve/seeds.rb
576
587
  - lib/hecks/grammar/expression.bluebook
588
+ - lib/hecks/grammar/expression.oidc.json
577
589
  - lib/hecks/grammar/expression_operators.json
578
590
  - lib/hecks/grammar/grammar.hecksagon
579
- - lib/hecks/grammar/oidc.json
580
591
  - lib/hecks/grammar/operators.rb
581
592
  - lib/hecks/grammar/translation.bluebook
593
+ - lib/hecks/grammar/translation.oidc.json
582
594
  - lib/hecks/hecks/adapters/bluebook_report.rb
583
595
  - lib/hecks/hecks/adapters/codebase/codemods.rb
584
596
  - lib/hecks/hecks/adapters/codebase/conformance.rb
@@ -1177,7 +1189,10 @@ files:
1177
1189
  - lib/hecks/projections/site/edge/behaviours.rb
1178
1190
  - lib/hecks/projections/site/edge/checks.rb
1179
1191
  - lib/hecks/projections/site/edge/findings.rb
1192
+ - lib/hecks/projections/site/edge/function_checks.rb
1193
+ - lib/hecks/projections/site/edge/function_code.rb
1180
1194
  - lib/hecks/projections/site/edge/pattern.rb
1195
+ - lib/hecks/projections/site/edge/redirect_host.js.tmpl
1181
1196
  - lib/hecks/projections/site/edge/route_checks.rb
1182
1197
  - lib/hecks/projections/site/edge/row_reader.rb
1183
1198
  - lib/hecks/projections/site/edge/rules.rb
@@ -1622,9 +1637,9 @@ files:
1622
1637
  - lib/rubocop/cop/hecks/symbol_string_key_mix.rb
1623
1638
  - lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb
1624
1639
  - lib/rubocop/cop/hecks/untyped_raise.rb
1625
- - prebuilt/9ee54e2f0d4d6d3fe046827779b221e02de665fba911a048ae5dd8b8fbfb7d90.marshal
1626
- - prebuilt/d74a00e996222c1d8b676d5959092effc46b4dc59e0555f3bf9554ac58e3094a.marshal
1627
- - prebuilt/verdicts-df19f35868d3284a70f55e42dcef87ef4e65e6a678e5135f72e1f6cff4c1357b.json
1640
+ - prebuilt/1a6f596be124228be93eba1d86dbc9d2933190fa389b284331241cb9f27115a0.marshal
1641
+ - prebuilt/a883a7e568667aa5ac904c82daff356bdf9e996252a36af6def4a4a43efb967e.marshal
1642
+ - prebuilt/verdicts-872993b1aa26d73fe48f28251374b8585fed145d02aab42be0f00de386b6ff80.json
1628
1643
  - qa/settings.yml
1629
1644
  - rust/.cargo/config.toml
1630
1645
  - rust/.cargo/mutants.toml
@@ -1720,6 +1735,7 @@ files:
1720
1735
  - rust/host/src/needs.rs
1721
1736
  - rust/host/src/payments.rs
1722
1737
  - rust/host/src/payments/keystore.rs
1738
+ - rust/host/src/payments/roles.rs
1723
1739
  - rust/host/src/payments/tests.rs
1724
1740
  - rust/host/src/platform_signin/attack_tests.rs
1725
1741
  - rust/host/src/platform_signin/config.rs
@@ -1790,6 +1806,7 @@ files:
1790
1806
  - rust/parser/src/build/closed_sets.rs
1791
1807
  - rust/parser/src/build/identity.rs
1792
1808
  - rust/parser/src/build/mod.rs
1809
+ - rust/parser/src/build/named_pattern.rs
1793
1810
  - rust/parser/src/build/naming.rs
1794
1811
  - rust/parser/src/build/pattern_subset.rs
1795
1812
  - rust/parser/src/build/query_derive.rs