hecks 2.6.0 → 2.8.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 (216) hide show
  1. checksums.yaml +4 -4
  2. data/exe/hecks +13 -0
  3. data/lib/hecks/adapters/driven/google_authentication.rb +1 -1
  4. data/lib/hecks/adapters/driven/lambda/client.rb +1 -1
  5. data/lib/hecks/adapters/driven/memory.rb +1 -1
  6. data/lib/hecks/adapters/driven/postgres.rb +1 -1
  7. data/lib/hecks/adapters/driven/prism.rb +2 -2
  8. data/lib/hecks/adapters/driven/sqlite/projection.rb +1 -1
  9. data/lib/hecks/adapters/driven/tenant_provisioner.rb +31 -10
  10. data/lib/hecks/behaviors/expectations.rb +2 -2
  11. data/lib/hecks/bluebook/assembly/contract.rb +1 -1
  12. data/lib/hecks/bluebook/assembly/contracts.rb +5 -5
  13. data/lib/hecks/bluebook/assembly/specializer.rb +2 -2
  14. data/lib/hecks/bluebook/assembly.rb +1 -1
  15. data/lib/hecks/bluebook/behaviour/chapter.rb +5 -2
  16. data/lib/hecks/bluebook/behaviour/read_model.rb +18 -0
  17. data/lib/hecks/bluebook/capabilities.rb +2 -2
  18. data/lib/hecks/bluebook/chapter.rb +1 -1
  19. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +2 -2
  20. data/lib/hecks/bluebook/dsl/binding_proxy.rb +6 -1
  21. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +2 -2
  22. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -0
  23. data/lib/hecks/bluebook/dsl/command_builder.rb +3 -3
  24. data/lib/hecks/bluebook/dsl/entity_builder.rb +1 -1
  25. data/lib/hecks/bluebook/dsl/generic_dispatch.rb +1 -1
  26. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +14 -9
  27. data/lib/hecks/bluebook/dsl/port_operation_builder.rb +1 -1
  28. data/lib/hecks/bluebook/dsl/rule_reference.rb +3 -3
  29. data/lib/hecks/bluebook/dsl/translation_builder.rb +1 -1
  30. data/lib/hecks/bluebook/dsl/world_builder.rb +47 -3
  31. data/lib/hecks/bluebook/hexagon.rb +15 -7
  32. data/lib/hecks/bluebook/meta_validator/judge.rb +3 -3
  33. data/lib/hecks/bluebook/meta_validator/plan.rb +4 -4
  34. data/lib/hecks/bluebook/meta_validator/readings.rb +1 -1
  35. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +2 -2
  36. data/lib/hecks/bluebook/meta_validator/shapes.rb +1 -1
  37. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +11 -10
  38. data/lib/hecks/bluebook/meta_validator/world_judge.rb +3 -1
  39. data/lib/hecks/bluebook/meta_validator.rb +1 -1
  40. data/lib/hecks/bluebook/model_check/client_profile.rb +4 -70
  41. data/lib/hecks/bluebook/model_check.rb +13 -14
  42. data/lib/hecks/cache_dir.rb +103 -0
  43. data/lib/hecks/cli/document.rb +104 -0
  44. data/lib/hecks/cli/ir.rb +47 -0
  45. data/lib/hecks/cli/mcp.rb +32 -0
  46. data/lib/hecks/cli/mcp_door.rb +461 -0
  47. data/lib/hecks/cli/model_check.rb +270 -0
  48. data/lib/hecks/cli/project_cli.rb +123 -0
  49. data/lib/hecks/cli/project_diagrams.rb +43 -0
  50. data/lib/hecks/cli/run.rb +219 -0
  51. data/lib/hecks/cli/smoke_test.rb +59 -0
  52. data/lib/hecks/cli/stores.rb +54 -0
  53. data/lib/hecks/cli.rb +166 -0
  54. data/lib/hecks/embryonaut_bluebook/lock.rb +72 -0
  55. data/lib/hecks/embryonaut_bluebook/shape.rb +47 -0
  56. data/lib/hecks/embryonaut_bluebook/vendor.rb +194 -0
  57. data/lib/hecks/embryonaut_bluebook/vendor_cli.rb +88 -0
  58. data/lib/hecks/embryonaut_bluebook.rb +29 -3
  59. data/lib/hecks/facade/handle.rb +2 -2
  60. data/lib/hecks/facade/surface/aggregate_door.rb +1 -1
  61. data/lib/hecks/forms/value_object_shape.rb +1 -1
  62. data/lib/hecks/framework/bluebook/console_settings.bluebook +3 -3
  63. data/lib/hecks/language/bluebook/vocabulary.bluebook +16 -0
  64. data/lib/hecks/language/world/world.bluebook +32 -2
  65. data/lib/hecks/mcp_door_scope.rb +176 -0
  66. data/lib/hecks/ports/key_vault.rb +1 -1
  67. data/lib/hecks/ports/persistence/append_only.rb +1 -1
  68. data/lib/hecks/ports/persistence/binding_policy.rb +16 -5
  69. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +3 -2
  70. data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +2 -2
  71. data/lib/hecks/ports/persistence/plugins/era/era_tamper.rb +2 -2
  72. data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +132 -0
  73. data/lib/hecks/ports/persistence/plugins/era/lineage.rb +1 -1
  74. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +2 -2
  75. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +1 -1
  76. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +1 -1
  77. data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +13 -7
  78. data/lib/hecks/ports/persistence/plugins/era.rb +7 -4
  79. data/lib/hecks/ports/persistence/postgres_dump/connection.rb +73 -0
  80. data/lib/hecks/ports/persistence/postgres_dump.rb +125 -0
  81. data/lib/hecks/ports/persistence/repository_factory.rb +2 -2
  82. data/lib/hecks/ports/persistence/state_codec.rb +1 -1
  83. data/lib/hecks/ports/persistence.rb +4 -0
  84. data/lib/hecks/projections/deploy/fargate/assembly.rb +193 -0
  85. data/lib/hecks/projections/deploy/fargate/cdn.rb +318 -0
  86. data/lib/hecks/projections/deploy/fargate/cdn_default.rb +87 -0
  87. data/lib/hecks/projections/deploy/fargate/check.rb +172 -0
  88. data/lib/hecks/projections/deploy/fargate/containers.rb +412 -0
  89. data/lib/hecks/projections/deploy/fargate/extras.rb +268 -0
  90. data/lib/hecks/projections/deploy/fargate/monitoring.rb +448 -0
  91. data/lib/hecks/projections/deploy/fargate/settings.rb +235 -0
  92. data/lib/hecks/projections/deploy/fargate/yaml.rb +116 -0
  93. data/lib/hecks/projections/deploy/fargate.rb +188 -139
  94. data/lib/hecks/projections/deploy/lambda.rb +8 -8
  95. data/lib/hecks/projections/deploy/preview/access_yaml.rb +145 -0
  96. data/lib/hecks/projections/deploy/preview/containers.rb +218 -0
  97. data/lib/hecks/projections/deploy/preview/preview.sh.erb +446 -0
  98. data/lib/hecks/projections/deploy/preview/routing_yaml.rb +216 -0
  99. data/lib/hecks/projections/deploy/preview/script.rb +40 -0
  100. data/lib/hecks/projections/deploy/preview/settings.rb +140 -0
  101. data/lib/hecks/projections/deploy/preview/task_yaml.rb +215 -0
  102. data/lib/hecks/projections/deploy/preview/template.rb +247 -0
  103. data/lib/hecks/projections/deploy/preview/yaml_text.rb +48 -0
  104. data/lib/hecks/projections/deploy/preview.rb +103 -0
  105. data/lib/hecks/projections/deploy/scripts/deploy-service.sh.tmpl +182 -0
  106. data/lib/hecks/projections/deploy/scripts/expected-era.header +17 -0
  107. data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +54 -0
  108. data/lib/hecks/projections/deploy/scripts/settings.rb +114 -0
  109. data/lib/hecks/projections/deploy/scripts/smoke-after-deploy.sh.tmpl +232 -0
  110. data/lib/hecks/projections/deploy/scripts.rb +171 -0
  111. data/lib/hecks/projections/deploy/smoke/harness.js +366 -0
  112. data/lib/hecks/projections/deploy/smoke/workflow.yml.tmpl +56 -0
  113. data/lib/hecks/projections/deploy/smoke.rb +159 -0
  114. data/lib/hecks/projections/deploy/template_diff/comparer.rb +231 -0
  115. data/lib/hecks/projections/deploy/template_diff/loader.rb +105 -0
  116. data/lib/hecks/projections/deploy/template_diff.rb +173 -0
  117. data/lib/hecks/projections/glossary/sensitivity.rb +1 -1
  118. data/lib/hecks/projections/reference.rb +7 -1
  119. data/lib/hecks/projections.rb +2 -0
  120. data/lib/hecks/projector/exporter.rb +4 -4
  121. data/lib/hecks/projector/target.rb +1 -1
  122. data/lib/hecks/release/gem_pin/published_versions.rb +58 -0
  123. data/lib/hecks/release/gem_pin.rb +112 -0
  124. data/lib/hecks/release/runner/ci_publisher.rb +96 -0
  125. data/lib/hecks/release/runner/commands.rb +68 -0
  126. data/lib/hecks/release/runner/console.rb +52 -0
  127. data/lib/hecks/release/runner/gem_publisher.rb +44 -0
  128. data/lib/hecks/release/runner/git.rb +48 -0
  129. data/lib/hecks/release/runner/npm_publisher.rb +89 -0
  130. data/lib/hecks/release/runner/preflight.rb +119 -0
  131. data/lib/hecks/release/runner/published.rb +55 -0
  132. data/lib/hecks/release/runner/tagger.rb +87 -0
  133. data/lib/hecks/release/runner.rb +244 -0
  134. data/lib/hecks/router/namespace_installer.rb +1 -1
  135. data/lib/hecks/runtime/command_interpreter.rb +3 -3
  136. data/lib/hecks/runtime/command_rules/admissibility.rb +2 -2
  137. data/lib/hecks/runtime/command_rules/arithmetic.rb +2 -2
  138. data/lib/hecks/runtime/entity_element.rb +1 -1
  139. data/lib/hecks/runtime/entity_interpreter.rb +1 -1
  140. data/lib/hecks/runtime/invocation.rb +1 -1
  141. data/lib/hecks/runtime/loader.rb +27 -3
  142. data/lib/hecks/runtime/outbox.rb +1 -1
  143. data/lib/hecks/runtime/reaction_invocation.rb +3 -3
  144. data/lib/hecks/runtime/read_model_interpreter.rb +37 -15
  145. data/lib/hecks/runtime/reference_hop.rb +4 -4
  146. data/lib/hecks/runtime/registry/saga_persistence.rb +8 -2
  147. data/lib/hecks/runtime/registry/verification.rb +15 -13
  148. data/lib/hecks/runtime/registry/world_defaults.rb +106 -0
  149. data/lib/hecks/runtime/registry.rb +22 -13
  150. data/lib/hecks/runtime/saga_interpreter.rb +1 -1
  151. data/lib/hecks/runtime/tenant_check.rb +1 -1
  152. data/lib/hecks/runtime/value/coercion.rb +3 -3
  153. data/lib/hecks/runtime/value/entity_list_coercion.rb +1 -1
  154. data/lib/hecks/storehouse.rb +18 -12
  155. data/lib/hecks/vendoring/git_environment.rb +35 -0
  156. data/lib/hecks/vendoring/git_source.rb +102 -0
  157. data/lib/hecks/vendoring.rb +102 -0
  158. data/lib/hecks/version.rb +1 -1
  159. data/lib/hecks/vocabulary.rb +5 -0
  160. data/lib/hecks.rb +8 -2
  161. metadata +72 -58
  162. data/lib/hecks/bench/cli.rb +0 -74
  163. data/lib/hecks/bench/environment.rb +0 -100
  164. data/lib/hecks/bench/postgres_probe.rb +0 -41
  165. data/lib/hecks/bench/report.rb +0 -116
  166. data/lib/hecks/bench/ruby_runner.rb +0 -151
  167. data/lib/hecks/bench/run.rb +0 -55
  168. data/lib/hecks/bench/rust_runner.rb +0 -165
  169. data/lib/hecks/bench/stats.rb +0 -75
  170. data/lib/hecks/bench/suite.rb +0 -172
  171. data/lib/hecks/bench/workload.rb +0 -137
  172. data/lib/hecks/bench.rb +0 -28
  173. data/lib/hecks/codemod.rb +0 -443
  174. data/lib/hecks/corpus.rb +0 -479
  175. data/lib/hecks/doc/reference.rb +0 -585
  176. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +0 -684
  177. data/lib/hecks/fuzzing/combination_miner.rb +0 -178
  178. data/lib/hecks/fuzzing/concurrent_dispatch.rb +0 -469
  179. data/lib/hecks/fuzzing/coverage_campaign.rb +0 -161
  180. data/lib/hecks/fuzzing/differential.rb +0 -192
  181. data/lib/hecks/fuzzing/domain_generator.rb +0 -871
  182. data/lib/hecks/fuzzing/era_boundary.rb +0 -154
  183. data/lib/hecks/fuzzing/form_census.rb +0 -285
  184. data/lib/hecks/fuzzing/generated_domain_check.rb +0 -171
  185. data/lib/hecks/fuzzing/invalid_value_generator.rb +0 -125
  186. data/lib/hecks/fuzzing/isolated_boot.rb +0 -604
  187. data/lib/hecks/fuzzing/nondeterministic.rb +0 -79
  188. data/lib/hecks/fuzzing/persistence_parity.rb +0 -253
  189. data/lib/hecks/fuzzing/properties/corrections.rb +0 -125
  190. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +0 -688
  191. data/lib/hecks/fuzzing/properties/guards.rb +0 -398
  192. data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +0 -399
  193. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +0 -137
  194. data/lib/hecks/fuzzing/properties/outbox.rb +0 -180
  195. data/lib/hecks/fuzzing/properties/querying.rb +0 -260
  196. data/lib/hecks/fuzzing/properties.rb +0 -267
  197. data/lib/hecks/fuzzing/qa_settings.rb +0 -164
  198. data/lib/hecks/fuzzing/replay.rb +0 -930
  199. data/lib/hecks/fuzzing/rotation_priority.rb +0 -114
  200. data/lib/hecks/fuzzing/rust_gap_manifest.rb +0 -139
  201. data/lib/hecks/fuzzing/self_consistency.rb +0 -825
  202. data/lib/hecks/fuzzing/sequence_generator/adversary.rb +0 -532
  203. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +0 -171
  204. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +0 -127
  205. data/lib/hecks/fuzzing/sequence_generator/picker.rb +0 -116
  206. data/lib/hecks/fuzzing/sequence_generator/query_binding.rb +0 -138
  207. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +0 -344
  208. data/lib/hecks/fuzzing/sequence_generator.rb +0 -311
  209. data/lib/hecks/fuzzing/shrinker.rb +0 -309
  210. data/lib/hecks/fuzzing/structural_skips.rb +0 -53
  211. data/lib/hecks/fuzzing/sweep_depth.rb +0 -61
  212. data/lib/hecks/fuzzing/target_capabilities.rb +0 -255
  213. data/lib/hecks/fuzzing/value_generator.rb +0 -340
  214. data/lib/hecks/fuzzing.rb +0 -23
  215. data/lib/hecks/grammar/evolve.rb +0 -524
  216. data/lib/hecks/query_ir.rb +0 -482
@@ -0,0 +1,366 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ // A client-independent smoke harness for a deployed Hecks stack.
5
+ //
6
+ // Emitted by `Hecks::Projections::Deploy::Smoke` when a domain's
7
+ // `deployed_to` block opts in with `smoke true`. It owns what every stack
8
+ // needs and nothing about any one site: the check runner and its summary,
9
+ // plain HTTP helpers, HMAC signed claims and session cookies, sandbox guest
10
+ // addresses, the expected-era check against `GET /version`, and the sweep
11
+ // that takes a run's own rows (and stale leftovers) back out. What a page
12
+ // or flow should answer stays in the client's own config module.
13
+ //
14
+ // node smoke/harness.js smoke/config.js
15
+ //
16
+ // ## The config module
17
+ //
18
+ // A CommonJS module exporting an object:
19
+ //
20
+ // siteUrl Base URL; `SMOKE_SITE_URL` overrides it. Required.
21
+ // cookieName Session cookie the site reads. Required.
22
+ // secretEnv Env var holding the cookie-signing secret (default `SESSION_SECRET`).
23
+ // linkSecretEnv Optional env var for a separate signing secret for emailed links;
24
+ // when it is set, links are signed with it as-is instead of with
25
+ // the per-purpose key `<purpose>:<secret>`.
26
+ // adminEmail Address minted into session cookies; `SMOKE_ADMIN_EMAIL` overrides.
27
+ // preflightPath Path that must answer 200 before anything runs (default `/`).
28
+ // era `{ path, file, authenticated }` turns on the era check. `path` is
29
+ // where the site relays the domain's `GET /version` (default
30
+ // `/version`); `file` is the allow-list (see below); `authenticated`
31
+ // sends a session cookie for `adminEmail`.
32
+ // sandbox `{ localPart, domain, patterns }` shapes the safe-mode guest
33
+ // addresses; `patterns` are extra regexps that also count as smoke rows.
34
+ // baseDir Directory `era.file` resolves against (the CLI sets it to the
35
+ // config module's own directory).
36
+ // before(ctx) Optional; runs first, for example to launch a browser.
37
+ // checks Array of `async (ctx) => {}`; each calls `ctx.check(label, fn)`.
38
+ // sweeps Array of `{ label, run(ctx) }`; run after every check, even when
39
+ // checks failed, so a run cleans up after itself. `run` may return
40
+ // `{ skipped }`.
41
+ // after(ctx) Optional; runs last, for example to close a browser.
42
+ //
43
+ // Every check runs even when an earlier one fails, so one run surfaces
44
+ // everything that is wrong. The process exits 1 when any check or sweep failed.
45
+ //
46
+ // ## SMOKE_MODE
47
+ //
48
+ // `safe` (the default) tags every guest address with a sandbox mailbox that
49
+ // accepts mail and never reaches an inbox, so a run against production is
50
+ // recognisable and never mails a real person. `full` is for a throwaway
51
+ // database and uses plain example.com addresses.
52
+ //
53
+ // ## The era allow-list
54
+ //
55
+ // `GET /version` reports the era the domain booted on. `era.file` lists the
56
+ // eras allowed to be live, one id per line; blank lines and lines starting
57
+ // with `#` are ignored. `SMOKE_EXPECT_ERA` (comma-separated) replaces the
58
+ // file. With no eras listed the check only requires that an era is reported.
59
+ // A rollback or an unplanned roll fails here.
60
+
61
+ const crypto = require("crypto");
62
+ const fs = require("fs");
63
+ const http = require("http");
64
+ const https = require("https");
65
+ const path = require("path");
66
+
67
+ const REQUEST_TIMEOUT_MS = 8000;
68
+ const STALE_MS = 30 * 60 * 1000;
69
+ const SWEEP_LIMIT = 300;
70
+ const REFUSAL_STATUSES = [301, 302, 303, 307, 308, 401, 403];
71
+
72
+ // One request, resolved with the status, headers and body text; redirects are
73
+ // reported, never followed. The module is picked from the URL's scheme, because
74
+ // node's `http` throws on an `https://` URL.
75
+ function request(method, url, { headers = {}, body = null, timeoutMs = REQUEST_TIMEOUT_MS } = {}) {
76
+ const client = url.startsWith("https://") ? https : http;
77
+ return new Promise((resolve, reject) => {
78
+ const req = client.request(url, { method, headers }, (res) => {
79
+ const chunks = [];
80
+ res.on("data", (chunk) => chunks.push(chunk));
81
+ res.on("end", () =>
82
+ resolve({
83
+ status: res.statusCode,
84
+ location: res.headers.location || "",
85
+ headers: res.headers,
86
+ body: Buffer.concat(chunks).toString("utf8"),
87
+ }),
88
+ );
89
+ });
90
+ req.on("error", reject);
91
+ req.setTimeout(timeoutMs, () => req.destroy(new Error(`timeout on ${method} ${url}`)));
92
+ req.end(body);
93
+ });
94
+ }
95
+
96
+ const httpGet = (url, headers = {}, options = {}) => request("GET", url, { headers, ...options });
97
+ const httpRequest = (method, url, headers = {}, body = null, options = {}) =>
98
+ request(method, url, { headers, body, ...options });
99
+
100
+ // A claim signed the way a Hecks host signs its own tokens: base64url JSON,
101
+ // then the hex HMAC-SHA256 of that string, joined with a dot.
102
+ function signedClaim(secret, claims, ttlSecs) {
103
+ const payload = { ...claims, exp: Math.floor(Date.now() / 1000) + ttlSecs };
104
+ const encoded = Buffer.from(JSON.stringify(payload)).toString("base64url");
105
+ const signature = crypto.createHmac("sha256", secret).update(encoded).digest("hex");
106
+ return `${encoded}.${signature}`;
107
+ }
108
+
109
+ const accountToken = (secret, email, ttlSecs = 3600) => signedClaim(secret, { email }, ttlSecs);
110
+
111
+ // The same token with its last signature digit changed, for proving a host
112
+ // verifies the signature and not just the token's shape.
113
+ function alteredSignature(token) {
114
+ const [encoded, signature] = token.split(".");
115
+ const last = signature.endsWith("0") ? "1" : "0";
116
+ return `${encoded}.${signature.slice(0, -1)}${last}`;
117
+ }
118
+
119
+ const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
120
+
121
+ function parseEraList(text) {
122
+ return text
123
+ .split("\n")
124
+ .map((line) => line.trim())
125
+ .filter((line) => line && !line.startsWith("#"));
126
+ }
127
+
128
+ // The eras the domain may be running: `SMOKE_EXPECT_ERA` when set, otherwise the
129
+ // file's ids. An unreadable or absent file means "do not compare".
130
+ function expectedEras(env, file) {
131
+ if (env.SMOKE_EXPECT_ERA !== undefined) {
132
+ return env.SMOKE_EXPECT_ERA.split(",").map((era) => era.trim()).filter(Boolean);
133
+ }
134
+ if (!file) return [];
135
+ try {
136
+ return parseEraList(fs.readFileSync(file, "utf8"));
137
+ } catch {
138
+ return [];
139
+ }
140
+ }
141
+
142
+ // The start time a run id carries (`smoke-<pid>-<ms>`), or null for any other form.
143
+ function runStartedAt(text) {
144
+ const match = /smoke-\d+-(\d{10,})/.exec(text || "");
145
+ return match ? Number(match[1]) : null;
146
+ }
147
+
148
+ // A run id with no readable start time counts as stale: it cannot belong to a
149
+ // run that is still going.
150
+ function isStaleRun(text, now = Date.now()) {
151
+ const at = runStartedAt(text);
152
+ return at === null || now - at > STALE_MS;
153
+ }
154
+
155
+ function readSettings(config, env) {
156
+ if (!config || typeof config !== "object") throw new Error("the smoke config must export an object");
157
+ const siteUrl = env.SMOKE_SITE_URL || config.siteUrl;
158
+ if (!siteUrl) throw new Error("no site url: set siteUrl in the config or SMOKE_SITE_URL");
159
+ if (!config.cookieName) throw new Error("the smoke config needs a cookieName");
160
+ const mode = env.SMOKE_MODE || "safe";
161
+ if (!["safe", "full"].includes(mode)) throw new Error(`SMOKE_MODE must be safe or full, not ${mode}`);
162
+ return { siteUrl: siteUrl.replace(/\/+$/, ""), mode };
163
+ }
164
+
165
+ function createHarness(config, { env = process.env, out = (text) => process.stdout.write(text) } = {}) {
166
+ const { siteUrl, mode } = readSettings(config, env);
167
+ const secret = env[config.secretEnv || "SESSION_SECRET"] || "";
168
+ const linkSecret = (config.linkSecretEnv && env[config.linkSecretEnv]) || secret;
169
+ const adminEmail = env.SMOKE_ADMIN_EMAIL || config.adminEmail || "admin@example.com";
170
+ // The default mailbox is an email service's sandbox address: it reports the mail as
171
+ // delivered and never reaches an inbox, so it does not count against a sending domain.
172
+ const sandbox = { localPart: "delivered", domain: "resend.dev", patterns: [], ...(config.sandbox || {}) };
173
+ const baseDir = config.baseDir || process.cwd();
174
+ const runId = `smoke-${process.pid}-${Date.now()}`;
175
+ const failures = [];
176
+
177
+ // Runs one named check. A throw fails the check; `{ skipped }` reports a check
178
+ // that does not apply to this deployment yet.
179
+ async function check(label, fn) {
180
+ out(` ${label}... `);
181
+ try {
182
+ const result = await fn();
183
+ out(result && result.skipped ? `skipped (${result.skipped})\n` : "ok\n");
184
+ } catch (err) {
185
+ out(`FAILED: ${err.message}\n`);
186
+ failures.push(label);
187
+ }
188
+ }
189
+
190
+ const guestEmail = (tag) =>
191
+ mode === "safe"
192
+ ? `${sandbox.localPart}+${runId}-${tag}@${sandbox.domain}`
193
+ : `${runId}-${tag}@example.com`;
194
+
195
+ const smokeEmailPatterns = () => [
196
+ new RegExp(`^${escapeRegExp(sandbox.localPart)}\\+smoke-.+@${escapeRegExp(sandbox.domain)}$`, "i"),
197
+ ...sandbox.patterns,
198
+ ];
199
+ const isSmokeEmail = (email) => smokeEmailPatterns().some((pattern) => pattern.test(email || ""));
200
+
201
+ const sessionToken = (email = adminEmail, ttlSecs = 3600) => accountToken(secret, email, ttlSecs);
202
+ const cookieHeader = (value) => `${config.cookieName}=${value}`;
203
+ const sessionCookie = (email = adminEmail, ttlSecs = 3600) => cookieHeader(sessionToken(email, ttlSecs));
204
+
205
+ // A link's signing key is per purpose, so a link minted for one flow cannot be
206
+ // replayed for another, unless a separate link secret was configured.
207
+ const linkKey = (purpose) => (config.linkSecretEnv && env[config.linkSecretEnv] ? linkSecret : `${purpose}:${linkSecret}`);
208
+ const signedLink = (purpose, claims, ttlSecs = 600) => signedClaim(linkKey(purpose), { purpose, ...claims }, ttlSecs);
209
+
210
+ const url = (pathname) => `${siteUrl}${pathname}`;
211
+
212
+ // Reads the era off the site's relay of `GET /version` and compares it with
213
+ // the allow-list; two reads a moment apart must agree, since differing answers
214
+ // mean two hosts.
215
+ async function checkEra() {
216
+ const era = config.era || {};
217
+ const target = url(era.path || "/version");
218
+ let headers = {};
219
+ if (era.authenticated) {
220
+ if (!secret) return { skipped: "no signing secret to authenticate with" };
221
+ headers = { Cookie: sessionCookie() };
222
+ }
223
+ const res = await httpGet(target, headers);
224
+ if (res.status === 404) return { skipped: "the deployment has no /version route yet" };
225
+ if (res.status !== 200) throw new Error(`HTTP ${res.status}: ${res.body.slice(0, 200)}`);
226
+ const version = JSON.parse(res.body);
227
+ if (typeof version.era !== "string" || !version.era) throw new Error(`no era in ${res.body.slice(0, 200)}`);
228
+ const allowed = expectedEras(env, era.file && path.resolve(baseDir, era.file));
229
+ if (allowed.length && !allowed.includes(version.era)) {
230
+ throw new Error(`the domain is on era ${version.era} (build ${version.build}); expected one of ${allowed.join(", ")}`);
231
+ }
232
+ const again = JSON.parse((await httpGet(target, headers)).body);
233
+ if (again.era !== version.era) throw new Error(`the era changed between two reads: ${version.era} then ${again.era}`);
234
+ }
235
+
236
+ // Proves a protected page refuses cookies it must not honour: signed with the
237
+ // wrong secret, not a token at all, expired, and correctly signed but altered.
238
+ // The last two need the real secret and are skipped without one.
239
+ async function forgedCookieChecks({ path: pathname, refused = (res) => REFUSAL_STATUSES.includes(res.status), what = "open the page" }) {
240
+ const cases = [
241
+ ["a cookie signed with the wrong secret", () => accountToken(`not-the-secret-${runId}`, adminEmail)],
242
+ ["a cookie that is not a token at all", () => "garbage.value"],
243
+ ["an expired session cookie", () => secret && accountToken(secret, adminEmail, -60)],
244
+ ["a session cookie whose signature was altered", () => secret && alteredSignature(sessionToken())],
245
+ ];
246
+ for (const [label, mint] of cases) {
247
+ await check(`${label} does not ${what}`, async () => {
248
+ const value = mint();
249
+ if (!value) return { skipped: "no signing secret" };
250
+ const res = await httpGet(url(pathname), { Cookie: cookieHeader(value) });
251
+ if (!refused(res)) throw new Error(`answered HTTP ${res.status} ${JSON.stringify(res.location)}`);
252
+ });
253
+ }
254
+ }
255
+
256
+ // Takes a run's own rows, and in safe mode stale leftovers from a run that died
257
+ // before cleaning up, back out through the caller's own controls. Own rows go
258
+ // first so a run that stops at the limit has still cleaned up after itself. A
259
+ // leftover must be older than STALE_MS so two overlapping runs never remove
260
+ // each other's rows mid-check. Rows carry an `id`; `list` answers only rows still present.
261
+ async function sweepRows({ list, remove, isOwn, isLeftover = () => false, limit = SWEEP_LIMIT, describe = (row) => row.label || row.id }) {
262
+ const wanted = (row) => isOwn(row) || (mode === "safe" && isLeftover(row));
263
+ let rows = await list();
264
+ let removed = 0;
265
+ for (;;) {
266
+ const candidates = rows.filter(wanted);
267
+ const next = candidates.find(isOwn) || candidates[0];
268
+ if (!next) break;
269
+ const result = await remove(next);
270
+ if (result && result.skipped) return { removed, skipped: result.skipped };
271
+ removed += 1;
272
+ rows = await list();
273
+ if (rows.some((row) => row.id === next.id)) {
274
+ throw new Error(`removed ${describe(next)} but a fresh listing still has it`);
275
+ }
276
+ if (removed >= limit) break;
277
+ }
278
+ const left = rows.filter(isOwn).map(describe);
279
+ if (left.length) throw new Error(`this run's rows are still present after the sweep: ${left.join(", ")}`);
280
+ return { removed };
281
+ }
282
+
283
+ const context = {
284
+ siteUrl, mode, runId, secret, adminEmail, failures, check, url,
285
+ httpGet, httpRequest, signedClaim, accountToken, alteredSignature,
286
+ guestEmail, isSmokeEmail, isStaleRun,
287
+ sessionToken, sessionCookie, cookieHeader, signedLink, linkKey,
288
+ checkEra, forgedCookieChecks, sweepRows,
289
+ };
290
+
291
+ async function runStep(label, fn) {
292
+ try {
293
+ await fn(context);
294
+ } catch (err) {
295
+ out(` ${label}... FAILED: ${err.message}\n`);
296
+ failures.push(label);
297
+ }
298
+ }
299
+
300
+ async function preflight() {
301
+ const res = await httpGet(url(config.preflightPath || "/"));
302
+ if (res.status !== 200) throw new Error(`site not reachable at ${siteUrl} (HTTP ${res.status})`);
303
+ }
304
+
305
+ // Runs everything and answers the exit code: 0 when every check and sweep
306
+ // passed, 1 otherwise. A site that cannot be reached at all is one failure,
307
+ // not a screenful.
308
+ async function run() {
309
+ out(`== ${config.title || "smoke"} against ${siteUrl} (${mode}) ==\n\n`);
310
+ try {
311
+ await preflight();
312
+ } catch (err) {
313
+ out(`${err.message}\n`);
314
+ return 1;
315
+ }
316
+ try {
317
+ if (config.before) await runStep("before", config.before);
318
+ for (const [index, checks] of (config.checks || []).entries()) await runStep(`checks[${index}]`, checks);
319
+ if (config.era) {
320
+ out("\n== Domain version ==\n");
321
+ await check("the domain reports the era it is running, and it is an expected one", checkEra);
322
+ }
323
+ } finally {
324
+ if ((config.sweeps || []).length) out("\n== Cleanup ==\n");
325
+ for (const sweep of config.sweeps || []) {
326
+ await check(sweep.label, () => sweep.run(context));
327
+ }
328
+ if (config.after) await runStep("after", config.after);
329
+ }
330
+ return summarize();
331
+ }
332
+
333
+ function summarize() {
334
+ if (failures.length === 0) {
335
+ out("\nSMOKE TEST PASSED\n");
336
+ return 0;
337
+ }
338
+ out(`\nSMOKE TEST FAILED (${failures.length}):\n`);
339
+ for (const failure of failures) out(` - ${failure}\n`);
340
+ return 1;
341
+ }
342
+
343
+ return { ...context, run };
344
+ }
345
+
346
+ module.exports = {
347
+ createHarness, request, httpGet, httpRequest, signedClaim, accountToken, alteredSignature,
348
+ parseEraList, expectedEras, runStartedAt, isStaleRun, STALE_MS, SWEEP_LIMIT,
349
+ };
350
+
351
+ if (require.main === module) {
352
+ const configPath = process.argv[2] || process.env.SMOKE_CONFIG;
353
+ if (!configPath) {
354
+ console.error("usage: node harness.js <config module> (or set SMOKE_CONFIG)");
355
+ process.exit(2);
356
+ }
357
+ const resolved = path.resolve(configPath);
358
+ const config = { baseDir: path.dirname(resolved), ...require(resolved) };
359
+ createHarness(config)
360
+ .run()
361
+ .then((code) => process.exit(code))
362
+ .catch((err) => {
363
+ console.error(err);
364
+ process.exit(1);
365
+ });
366
+ }
@@ -0,0 +1,56 @@
1
+ # Runs the smoke harness against the live site on a schedule and on demand.
2
+ #
3
+ # Emitted by `Hecks::Projections::Deploy::Smoke`; copy it to `.github/workflows/`.
4
+ # The run is safe on production by design: the harness's `SMOKE_MODE=safe` tags every
5
+ # guest address with a sandbox mailbox, so no inbox is ever read or mailed.
6
+ #
7
+ # AWS access is by OIDC, with no long-lived keys. GitHub's own token is exchanged for
8
+ # temporary credentials for a role whose trust policy names only this repository, and
9
+ # whose one permission is `secretsmanager:GetSecretValue` on the single secret below.
10
+ # The role is created once, by hand, outside this stack.
11
+ name: '@@name@@'
12
+
13
+ on:
14
+ workflow_dispatch: {}
15
+ schedule:
16
+ - cron: '@@schedule@@'
17
+
18
+ permissions:
19
+ id-token: write # the OIDC token exchange below
20
+ contents: read
21
+
22
+ jobs:
23
+ smoke:
24
+ runs-on: ubuntu-latest
25
+
26
+ steps:
27
+ - uses: actions/checkout@v4
28
+
29
+ - uses: actions/setup-node@v4
30
+ with:
31
+ node-version: '22'
32
+
33
+ @@setup@@
34
+ - name: Configure AWS credentials (OIDC, no long-lived keys)
35
+ uses: aws-actions/configure-aws-credentials@v4
36
+ with:
37
+ role-to-assume: '@@role_arn@@'
38
+ aws-region: '@@region@@'
39
+
40
+ - name: Fetch the signing secret
41
+ id: secret
42
+ run: |
43
+ SECRET=$(aws secretsmanager get-secret-value \
44
+ --secret-id '@@secret_id@@' \
45
+ --query SecretString --output text@@secret_field_pipe@@)
46
+ # Masked in every later log line; the narrow IAM policy is what keeps
47
+ # the secret contained, this only keeps it out of the logs.
48
+ echo "::add-mask::$SECRET"
49
+ echo "value=$SECRET" >> "$GITHUB_OUTPUT"
50
+
51
+ - name: Run the smoke test
52
+ env:
53
+ SMOKE_MODE: safe
54
+ SMOKE_SITE_URL: '@@site_url@@'
55
+ @@secret_env@@: ${{ steps.secret.outputs.value }}
56
+ run: node @@harness@@ @@config@@
@@ -0,0 +1,159 @@
1
+ module Hecks
2
+ module Projections
3
+ module Deploy
4
+ # The smoke-test files a deployed stack can opt in to: a client-independent
5
+ # JavaScript harness and a GitHub Actions workflow that runs it on a schedule.
6
+ #
7
+ # Not a registered `Projector::Target` of its own. It adds files to whatever a
8
+ # deploy target already produced, so `bin/project_deploy` merges what `files`
9
+ # returns into the artifact, and a stack that has not opted in gets nothing.
10
+ #
11
+ # ## Opting in
12
+ #
13
+ # In the domain's `.world`, inside its `deployed_to` block:
14
+ #
15
+ # deployed_to("AwsFargate") do
16
+ # smoke true
17
+ # smoke_role_arn "arn:aws:iam::123456789012:role/example-smoke"
18
+ # smoke_secret_id "example-stack/session-secret"
19
+ # smoke_site_url "https://example.org"
20
+ # end
21
+ #
22
+ # `smoke_secret_field` names a JSON field when the secret is a JSON document,
23
+ # `smoke_secret_env` names the variable the harness reads it from (default
24
+ # `SESSION_SECRET`), `smoke_schedule` is a cron expression (default every 15
25
+ # minutes), `smoke_harness` and `smoke_config` are the repository-relative paths
26
+ # the workflow runs (defaults `smoke/harness.js` and `smoke/config.js`), and
27
+ # `smoke_setup` is one shell command run before the AWS steps, such as an
28
+ # install step the client's checks need.
29
+ #
30
+ # ## What stays with the client
31
+ #
32
+ # The harness knows nothing about any site. The pages and flows to assert, the
33
+ # cookie name, and the expected-era file are the client's own `config.js`, which
34
+ # this never writes.
35
+ module Smoke
36
+ TEMPLATES = File.expand_path("smoke", __dir__).freeze
37
+ DEFAULTS = {
38
+ secret_env: "SESSION_SECRET",
39
+ schedule: "*/15 * * * *",
40
+ harness: "smoke/harness.js",
41
+ config: "smoke/config.js"
42
+ }.freeze
43
+
44
+ # Each value is checked against the shape it is spliced into, so a setting
45
+ # can never break out of the YAML or shell line it lands on.
46
+ PATTERNS = {
47
+ role_arn: %r{\Aarn:aws:iam::\d{12}:role/[\w+=,.@/-]+\z},
48
+ secret_id: %r{\A[\w/+=.@-]+\z},
49
+ secret_field: /\A\w+\z/,
50
+ secret_env: /\A[A-Z_][A-Z0-9_]*\z/,
51
+ site_url: %r{\Ahttps?://[\w.-]+(:\d+)?(/[\w./-]*)?\z},
52
+ schedule: /\A(\S+ ){4}\S+\z/,
53
+ harness: %r{\A[\w./-]+\z},
54
+ config: %r{\A[\w./-]+\z},
55
+ region: /\A[a-z]{2}(-[a-z]+)+-\d\z/,
56
+ setup: /\A[^\r\n]+\z/
57
+ }.freeze
58
+ REQUIRED = %i[role_arn secret_id site_url region].freeze
59
+
60
+ module_function
61
+
62
+ # Whether the domain's `deployed_to` settings ask for the smoke files.
63
+ #
64
+ # @param deploy_settings [Hash{Symbol => Object}] the `deployed_to` block's settings
65
+ # @return [Boolean] true only when `smoke true` was declared
66
+ def enabled?(deploy_settings)
67
+ deploy_settings[:smoke] == true
68
+ end
69
+
70
+ # The files to add to a deploy artifact: the harness, and the workflow rendered
71
+ # from the domain's settings. Empty unless the domain opted in.
72
+ #
73
+ # @param deploy_settings [Hash{Symbol => Object}] the `deployed_to` block's settings;
74
+ # `:region` and the `smoke_*` settings are read
75
+ # @param stack_name [String] names the workflow
76
+ # @return [Hash{String => String}] `"smoke/harness.js"` and `"smoke/workflow.yml"`,
77
+ # or `{}` when the domain did not opt in
78
+ # @raise [ArgumentError] if a required setting is missing or a value is not in the
79
+ # shape its place in the workflow allows
80
+ def files(deploy_settings, stack_name:)
81
+ return {} unless enabled?(deploy_settings)
82
+
83
+ values = resolve(deploy_settings).merge(name: "#{stack_name} smoke test")
84
+ {
85
+ "smoke/harness.js" => File.read(File.join(TEMPLATES, "harness.js")),
86
+ "smoke/workflow.yml" => render(File.read(File.join(TEMPLATES, "workflow.yml.tmpl")), values)
87
+ }
88
+ end
89
+
90
+ # Applies the defaults and validates every value.
91
+ #
92
+ # @param deploy_settings [Hash{Symbol => Object}] the `deployed_to` block's settings
93
+ # @return [Hash{Symbol => String, nil}] the template values, keyed by placeholder name
94
+ # @raise [ArgumentError] if a required setting is missing or a value does not match its
95
+ # pattern
96
+ def resolve(deploy_settings)
97
+ missing = REQUIRED.reject { |name| deploy_settings[setting_key(name)] }
98
+ unless missing.empty?
99
+ raise ArgumentError, "smoke true needs #{missing.map { |name| setting_key(name) }.join(', ')} " \
100
+ "in the deployed_to block; the workflow cannot be rendered without them"
101
+ end
102
+
103
+ picked = PATTERNS.keys.to_h { |name| [name, deploy_settings.fetch(setting_key(name), DEFAULTS[name])] }
104
+ PATTERNS.each do |name, pattern|
105
+ next if picked[name].nil? || picked[name].to_s.match?(pattern)
106
+
107
+ raise ArgumentError, "#{setting_key(name)} #{picked[name].inspect} is not a value the smoke workflow can carry"
108
+ end
109
+ picked
110
+ end
111
+
112
+ # The `deployed_to` setting that supplies a template value. The region is the
113
+ # deploy target's own; every other value has a `smoke_` prefix.
114
+ #
115
+ # @param name [Symbol] a template value name, such as `:role_arn`
116
+ # @return [Symbol] the setting key, such as `:smoke_role_arn`
117
+ def setting_key(name)
118
+ name == :region ? :region : :"smoke_#{name}"
119
+ end
120
+
121
+ # Fills the workflow template. A placeholder line that resolves to nothing
122
+ # disappears whole rather than leaving a blank.
123
+ #
124
+ # @param template [String] the workflow template text
125
+ # @param values [Hash{Symbol => String, nil}] placeholder values, plus `:name`
126
+ # @return [String] the rendered workflow
127
+ # @raise [ArgumentError] if a placeholder is left unfilled
128
+ def render(template, values)
129
+ filled = template.gsub(/^@@setup@@\n/) { setup_step(values[:setup]) }
130
+ filled = filled.gsub("@@secret_field_pipe@@") { secret_field_pipe(values[:secret_field]) }
131
+ values.except(:setup, :secret_field).each { |key, value| filled = filled.gsub("@@#{key}@@") { value.to_s } }
132
+ raise ArgumentError, "unfilled placeholder in the smoke workflow" if filled.include?("@@")
133
+
134
+ filled
135
+ end
136
+
137
+ # Renders the optional setup step, quoting the command for YAML.
138
+ #
139
+ # @param command [String, nil] the shell command from `smoke_setup`
140
+ # @return [String] a workflow step running it, or nothing
141
+ def setup_step(command)
142
+ return "" if command.nil?
143
+
144
+ " - name: Set up the smoke run\n run: '#{command.gsub("'", "''")}'\n\n"
145
+ end
146
+
147
+ # Builds the pipe that pulls one field out of a JSON secret document.
148
+ #
149
+ # @param field [String, nil] the JSON field of the secret document
150
+ # @return [String] the pipe that extracts it, or nothing when the secret is plain text
151
+ def secret_field_pipe(field)
152
+ return "" if field.nil?
153
+
154
+ " | node -e 'process.stdout.write(JSON.parse(require(\"fs\").readFileSync(0)).#{field})'"
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end