@intentius/chant-lexicon-terraform 0.124.54 → 0.124.56

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 (351) hide show
  1. package/README.md +7 -4
  2. package/dist/ci-setup.d.ts +11 -0
  3. package/dist/ci-setup.d.ts.map +1 -0
  4. package/dist/ci-setup.js +41 -0
  5. package/dist/components/capability-plugin.d.ts +40 -6
  6. package/dist/components/capability-plugin.d.ts.map +1 -1
  7. package/dist/components/capability-plugin.js +174 -30
  8. package/dist/components/compose-group.d.ts +1 -1
  9. package/dist/components/compose-group.d.ts.map +1 -1
  10. package/dist/components/compose-group.js +1 -1
  11. package/dist/components/release-inputs.d.ts +29 -0
  12. package/dist/components/release-inputs.d.ts.map +1 -0
  13. package/dist/components/release-inputs.js +57 -0
  14. package/dist/components/trace-facts.d.ts +16 -0
  15. package/dist/components/trace-facts.d.ts.map +1 -0
  16. package/dist/components/trace-facts.js +19 -0
  17. package/dist/cone.d.ts +53 -0
  18. package/dist/cone.d.ts.map +1 -0
  19. package/dist/cone.js +119 -0
  20. package/dist/cost-hold.d.ts +27 -0
  21. package/dist/cost-hold.d.ts.map +1 -0
  22. package/dist/cost-hold.js +21 -0
  23. package/dist/drift-rows.d.ts +61 -0
  24. package/dist/drift-rows.d.ts.map +1 -0
  25. package/dist/drift-rows.js +108 -0
  26. package/dist/exit-codes.d.ts +55 -0
  27. package/dist/exit-codes.d.ts.map +1 -0
  28. package/dist/exit-codes.js +48 -0
  29. package/dist/index.d.ts +2 -0
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +3 -0
  32. package/dist/integrity.json +7 -4
  33. package/dist/lint/audit-catalog.d.ts.map +1 -1
  34. package/dist/lint/audit-catalog.js +27 -3
  35. package/dist/lint/audit-lineage.d.ts +14 -2
  36. package/dist/lint/audit-lineage.d.ts.map +1 -1
  37. package/dist/lint/post-synth/index.d.ts.map +1 -1
  38. package/dist/lint/post-synth/index.js +4 -0
  39. package/dist/lint/post-synth/record-store.d.ts +54 -0
  40. package/dist/lint/post-synth/record-store.d.ts.map +1 -0
  41. package/dist/lint/post-synth/record-store.js +104 -0
  42. package/dist/lint/post-synth/tf001.d.ts +2 -2
  43. package/dist/lint/post-synth/tf001.js +2 -2
  44. package/dist/lint/post-synth/tf024.d.ts +35 -11
  45. package/dist/lint/post-synth/tf024.d.ts.map +1 -1
  46. package/dist/lint/post-synth/tf024.js +92 -20
  47. package/dist/lint/post-synth/tf046.d.ts +64 -0
  48. package/dist/lint/post-synth/tf046.d.ts.map +1 -0
  49. package/dist/lint/post-synth/tf046.js +175 -0
  50. package/dist/lint/post-synth/tf047.d.ts +36 -0
  51. package/dist/lint/post-synth/tf047.d.ts.map +1 -0
  52. package/dist/lint/post-synth/tf047.js +94 -0
  53. package/dist/lock-rows.d.ts +36 -0
  54. package/dist/lock-rows.d.ts.map +1 -0
  55. package/dist/lock-rows.js +60 -0
  56. package/dist/lock-timeout.d.ts +22 -0
  57. package/dist/lock-timeout.d.ts.map +1 -0
  58. package/dist/lock-timeout.js +32 -0
  59. package/dist/manifest.json +1 -1
  60. package/dist/module-catalog/index.d.ts +69 -0
  61. package/dist/module-catalog/index.d.ts.map +1 -0
  62. package/dist/module-catalog/index.js +110 -0
  63. package/dist/module-catalog/refresh-cli.d.ts +4 -0
  64. package/dist/module-catalog/refresh-cli.d.ts.map +1 -0
  65. package/dist/module-catalog/refresh-cli.js +64 -0
  66. package/dist/module-catalog/static-tier.d.ts +6 -0
  67. package/dist/module-catalog/static-tier.d.ts.map +1 -0
  68. package/dist/module-catalog/static-tier.js +1650 -0
  69. package/dist/module-catalog/types.d.ts +65 -0
  70. package/dist/module-catalog/types.d.ts.map +1 -0
  71. package/dist/module-catalog/types.js +33 -0
  72. package/dist/okf/index.md +3 -1
  73. package/dist/okf/rules/TF024.md +2 -2
  74. package/dist/okf/rules/TF046.md +11 -0
  75. package/dist/okf/rules/TF047.md +11 -0
  76. package/dist/op/activities/terraform.d.ts +29 -0
  77. package/dist/op/activities/terraform.d.ts.map +1 -1
  78. package/dist/op/activities/terraform.js +19 -4
  79. package/dist/pin/consumers.d.ts +35 -0
  80. package/dist/pin/consumers.d.ts.map +1 -0
  81. package/dist/pin/consumers.js +70 -0
  82. package/dist/plugin.d.ts.map +1 -1
  83. package/dist/plugin.js +5 -0
  84. package/dist/registry/address.d.ts +63 -0
  85. package/dist/registry/address.d.ts.map +1 -0
  86. package/dist/registry/address.js +65 -0
  87. package/dist/registry/catalog-read.d.ts +14 -0
  88. package/dist/registry/catalog-read.d.ts.map +1 -0
  89. package/dist/registry/catalog-read.js +58 -0
  90. package/dist/registry/deprecated.d.ts +37 -0
  91. package/dist/registry/deprecated.d.ts.map +1 -0
  92. package/dist/registry/deprecated.js +43 -0
  93. package/dist/registry/docs.d.ts +96 -0
  94. package/dist/registry/docs.d.ts.map +1 -0
  95. package/dist/registry/docs.js +210 -0
  96. package/dist/registry/edge/edge.d.ts +46 -0
  97. package/dist/registry/edge/edge.d.ts.map +1 -0
  98. package/dist/registry/edge/edge.js +194 -0
  99. package/dist/registry/edge/index.d.ts +11 -0
  100. package/dist/registry/edge/index.d.ts.map +1 -0
  101. package/dist/registry/edge/index.js +10 -0
  102. package/dist/registry/edge/lambda-edge.d.ts +76 -0
  103. package/dist/registry/edge/lambda-edge.d.ts.map +1 -0
  104. package/dist/registry/edge/lambda-edge.js +95 -0
  105. package/dist/registry/edge/sigv4-web.d.ts +33 -0
  106. package/dist/registry/edge/sigv4-web.d.ts.map +1 -0
  107. package/dist/registry/edge/sigv4-web.js +56 -0
  108. package/dist/registry/edge/token-source.d.ts +73 -0
  109. package/dist/registry/edge/token-source.d.ts.map +1 -0
  110. package/dist/registry/edge/token-source.js +115 -0
  111. package/dist/registry/edge/worker.d.ts +45 -0
  112. package/dist/registry/edge/worker.d.ts.map +1 -0
  113. package/dist/registry/edge/worker.js +26 -0
  114. package/dist/registry/handler.d.ts +25 -0
  115. package/dist/registry/handler.d.ts.map +1 -0
  116. package/dist/registry/handler.js +75 -0
  117. package/dist/registry/headers-file.d.ts +21 -0
  118. package/dist/registry/headers-file.d.ts.map +1 -0
  119. package/dist/registry/headers-file.js +57 -0
  120. package/dist/registry/index.d.ts +31 -0
  121. package/dist/registry/index.d.ts.map +1 -0
  122. package/dist/registry/index.js +30 -0
  123. package/dist/registry/mirror-paths.d.ts +9 -0
  124. package/dist/registry/mirror-paths.d.ts.map +1 -0
  125. package/dist/registry/mirror-paths.js +8 -0
  126. package/dist/registry/mirror.d.ts +130 -0
  127. package/dist/registry/mirror.d.ts.map +1 -0
  128. package/dist/registry/mirror.js +319 -0
  129. package/dist/registry/proxies.d.ts +73 -0
  130. package/dist/registry/proxies.d.ts.map +1 -0
  131. package/dist/registry/proxies.js +111 -0
  132. package/dist/registry/reader.d.ts +57 -0
  133. package/dist/registry/reader.d.ts.map +1 -0
  134. package/dist/registry/reader.js +198 -0
  135. package/dist/registry/semver.d.ts +16 -0
  136. package/dist/registry/semver.d.ts.map +1 -0
  137. package/dist/registry/semver.js +24 -0
  138. package/dist/registry/serve.d.ts +24 -0
  139. package/dist/registry/serve.d.ts.map +1 -0
  140. package/dist/registry/serve.js +64 -0
  141. package/dist/registry/source-forms.d.ts +79 -0
  142. package/dist/registry/source-forms.d.ts.map +1 -0
  143. package/dist/registry/source-forms.js +202 -0
  144. package/dist/registry/targets/artifactory.d.ts +28 -0
  145. package/dist/registry/targets/artifactory.d.ts.map +1 -0
  146. package/dist/registry/targets/artifactory.js +206 -0
  147. package/dist/registry/targets/fake-artifactory.d.ts +30 -0
  148. package/dist/registry/targets/fake-artifactory.d.ts.map +1 -0
  149. package/dist/registry/targets/fake-artifactory.js +142 -0
  150. package/dist/registry/targets/fake-gitlab.d.ts +39 -0
  151. package/dist/registry/targets/fake-gitlab.d.ts.map +1 -0
  152. package/dist/registry/targets/fake-gitlab.js +136 -0
  153. package/dist/registry/targets/fake-hcp.d.ts +50 -0
  154. package/dist/registry/targets/fake-hcp.d.ts.map +1 -0
  155. package/dist/registry/targets/fake-hcp.js +202 -0
  156. package/dist/registry/targets/fake-http.d.ts +31 -0
  157. package/dist/registry/targets/fake-http.d.ts.map +1 -0
  158. package/dist/registry/targets/fake-http.js +87 -0
  159. package/dist/registry/targets/fake-nexus.d.ts +27 -0
  160. package/dist/registry/targets/fake-nexus.d.ts.map +1 -0
  161. package/dist/registry/targets/fake-nexus.js +137 -0
  162. package/dist/registry/targets/gitlab.d.ts +53 -0
  163. package/dist/registry/targets/gitlab.d.ts.map +1 -0
  164. package/dist/registry/targets/gitlab.js +187 -0
  165. package/dist/registry/targets/hcp.d.ts +29 -0
  166. package/dist/registry/targets/hcp.d.ts.map +1 -0
  167. package/dist/registry/targets/hcp.js +310 -0
  168. package/dist/registry/targets/hosted.d.ts +62 -0
  169. package/dist/registry/targets/hosted.d.ts.map +1 -0
  170. package/dist/registry/targets/hosted.js +74 -0
  171. package/dist/registry/targets/index.d.ts +18 -0
  172. package/dist/registry/targets/index.d.ts.map +1 -0
  173. package/dist/registry/targets/index.js +17 -0
  174. package/dist/registry/targets/module-target.d.ts +68 -0
  175. package/dist/registry/targets/module-target.d.ts.map +1 -0
  176. package/dist/registry/targets/module-target.js +63 -0
  177. package/dist/registry/targets/nexus.d.ts +43 -0
  178. package/dist/registry/targets/nexus.d.ts.map +1 -0
  179. package/dist/registry/targets/nexus.js +250 -0
  180. package/dist/registry/targets/zip.d.ts +55 -0
  181. package/dist/registry/targets/zip.d.ts.map +1 -0
  182. package/dist/registry/targets/zip.js +278 -0
  183. package/dist/registry/writer.d.ts +101 -0
  184. package/dist/registry/writer.d.ts.map +1 -0
  185. package/dist/registry/writer.js +109 -0
  186. package/dist/rules/record-store.ts +122 -0
  187. package/dist/rules/tf001.ts +2 -2
  188. package/dist/rules/tf024.ts +105 -22
  189. package/dist/rules/tf046.ts +204 -0
  190. package/dist/rules/tf047.ts +114 -0
  191. package/dist/state/azure-blob.d.ts +8 -67
  192. package/dist/state/azure-blob.d.ts.map +1 -1
  193. package/dist/state/azure-blob.js +6 -199
  194. package/dist/state/gcs.d.ts +7 -61
  195. package/dist/state/gcs.d.ts.map +1 -1
  196. package/dist/state/gcs.js +5 -222
  197. package/dist/state/object-store.d.ts +4 -33
  198. package/dist/state/object-store.d.ts.map +1 -1
  199. package/dist/state/object-store.js +4 -17
  200. package/dist/state/s3.d.ts +11 -58
  201. package/dist/state/s3.d.ts.map +1 -1
  202. package/dist/state/s3.js +10 -161
  203. package/dist/state/store.js +2 -2
  204. package/dist/state-ops.d.ts +255 -0
  205. package/dist/state-ops.d.ts.map +1 -0
  206. package/dist/state-ops.js +305 -0
  207. package/dist/state-reads.d.ts +57 -0
  208. package/dist/state-reads.d.ts.map +1 -0
  209. package/dist/state-reads.js +104 -0
  210. package/dist/used-sources.d.ts +54 -0
  211. package/dist/used-sources.d.ts.map +1 -0
  212. package/dist/used-sources.js +157 -0
  213. package/package.json +19 -3
  214. package/src/__fixtures__/drift-record/README.md +5 -0
  215. package/src/__fixtures__/drift-rows/live-root-plan.json +70 -0
  216. package/src/__fixtures__/drift-rows/refresh-plan.json +61 -0
  217. package/src/__fixtures__/used-sources/root/extra.tofu +1 -0
  218. package/src/__fixtures__/used-sources/root/main.tf +31 -0
  219. package/src/__fixtures__/used-sources/root/modules/net/main.tf +14 -0
  220. package/src/__fixtures__/used-sources/root/modules/net/versions.tf.json +7 -0
  221. package/src/__fixtures__/used-sources/unit/terragrunt.hcl +3 -0
  222. package/src/ci-setup.ts +42 -0
  223. package/src/components/capability-plugin.test.ts +26 -14
  224. package/src/components/capability-plugin.ts +185 -31
  225. package/src/components/compose-group.ts +1 -1
  226. package/src/components/pr-loop-compose.tofu.e2e.test.ts +1 -1
  227. package/src/components/pr-loop.tofu.test.ts +1 -1
  228. package/src/components/release-inputs.ts +69 -0
  229. package/src/components/trace-facts.test.ts +21 -0
  230. package/src/components/trace-facts.ts +27 -0
  231. package/src/cone.ts +138 -0
  232. package/src/cost-hold.ts +29 -0
  233. package/src/describe-resources.engine-facts.test.ts +2 -3
  234. package/src/drift-rows.test.ts +135 -0
  235. package/src/drift-rows.ts +137 -0
  236. package/src/exit-codes.test.ts +23 -0
  237. package/src/exit-codes.ts +59 -0
  238. package/src/index.ts +3 -0
  239. package/src/lint/audit-catalog.ts +29 -3
  240. package/src/lint/audit-lineage.ts +14 -2
  241. package/src/lint/post-synth/fixtures/TF024/live-with-record-store/main.tf +18 -0
  242. package/src/lint/post-synth/fixtures/TF024/sidecar-with-backend/estate.chdf.hcl +1 -0
  243. package/src/lint/post-synth/fixtures/TF024/sidecar-with-backend/main.tf +10 -0
  244. package/src/lint/post-synth/fixtures/TF046/azurerm-no-resource-group/main.tf +15 -0
  245. package/src/lint/post-synth/fixtures/TF046/backend-config-only/main.tf +14 -0
  246. package/src/lint/post-synth/fixtures/TF046/derived-s3/backend.tf +7 -0
  247. package/src/lint/post-synth/fixtures/TF046/derived-s3/main.tf +9 -0
  248. package/src/lint/post-synth/fixtures/TF046/explicit-local/main.tf +16 -0
  249. package/src/lint/post-synth/fixtures/TF046/explicit-s3-beside-local-backend/main.tf +15 -0
  250. package/src/lint/post-synth/fixtures/TF046/implied-local/main.tf +9 -0
  251. package/src/lint/post-synth/fixtures/TF046/sidecar-implied/estate.chdf.hcl +1 -0
  252. package/src/lint/post-synth/fixtures/TF046/sidecar-implied/main.tf +5 -0
  253. package/src/lint/post-synth/fixtures/TF047/clear/main.tf +12 -0
  254. package/src/lint/post-synth/fixtures/TF047/refused/main.tf +12 -0
  255. package/src/lint/post-synth/fixtures/TF047/unknown-module/main.tf +12 -0
  256. package/src/lint/post-synth/fixtures/TF047/unknown-version/main.tf +12 -0
  257. package/src/lint/post-synth/index.ts +4 -0
  258. package/src/lint/post-synth/record-store.ts +122 -0
  259. package/src/lint/post-synth/tf001.ts +2 -2
  260. package/src/lint/post-synth/tf024.test.ts +26 -5
  261. package/src/lint/post-synth/tf024.ts +105 -22
  262. package/src/lint/post-synth/tf046.test.ts +137 -0
  263. package/src/lint/post-synth/tf046.ts +204 -0
  264. package/src/lint/post-synth/tf047.test.ts +56 -0
  265. package/src/lint/post-synth/tf047.ts +114 -0
  266. package/src/lock-rows.test.ts +70 -0
  267. package/src/lock-rows.ts +82 -0
  268. package/src/lock-timeout.ts +34 -0
  269. package/src/module-catalog/index.test.ts +93 -0
  270. package/src/module-catalog/index.ts +159 -0
  271. package/src/module-catalog/refresh-cli.ts +68 -0
  272. package/src/module-catalog/static-tier.ts +1659 -0
  273. package/src/module-catalog/types.ts +88 -0
  274. package/src/op/activities/terraform.ts +46 -5
  275. package/src/op-wave-step.test.ts +109 -0
  276. package/src/pin/__fixtures__/consumers/live/dev/main.tf +4 -0
  277. package/src/pin/__fixtures__/consumers/live/prod/main.tf +21 -0
  278. package/src/pin/__fixtures__/consumers/units/vpc/terragrunt.hcl +7 -0
  279. package/src/pin/consumers.test.ts +57 -0
  280. package/src/pin/consumers.ts +95 -0
  281. package/src/plugin.ts +6 -0
  282. package/src/registry/__fixtures__/docs/documented/.terraform-docs.yml +3 -0
  283. package/src/registry/__fixtures__/docs/documented/README.md +1 -0
  284. package/src/registry/__fixtures__/docs/documented/main.tf +8 -0
  285. package/src/registry/__fixtures__/docs/json/main.tf.json +14 -0
  286. package/src/registry/__fixtures__/docs/vpc/README.md +3 -0
  287. package/src/registry/__fixtures__/docs/vpc/examples/complete/main.tf +5 -0
  288. package/src/registry/__fixtures__/docs/vpc/examples/simple/README.md +1 -0
  289. package/src/registry/__fixtures__/docs/vpc/examples/simple/main.tf +4 -0
  290. package/src/registry/__fixtures__/docs/vpc/main.tf +14 -0
  291. package/src/registry/__fixtures__/docs/vpc/outputs.tf +9 -0
  292. package/src/registry/__fixtures__/docs/vpc/variables.tf +16 -0
  293. package/src/registry/__fixtures__/proxied-root/main.tf +12 -0
  294. package/src/registry/address.ts +100 -0
  295. package/src/registry/catalog-read.ts +56 -0
  296. package/src/registry/deprecated.test.ts +139 -0
  297. package/src/registry/deprecated.ts +76 -0
  298. package/src/registry/docs.test.ts +140 -0
  299. package/src/registry/docs.ts +299 -0
  300. package/src/registry/edge/bundle.test.ts +50 -0
  301. package/src/registry/edge/edge.test.ts +176 -0
  302. package/src/registry/edge/edge.ts +211 -0
  303. package/src/registry/edge/index.ts +10 -0
  304. package/src/registry/edge/lambda-edge.ts +127 -0
  305. package/src/registry/edge/sigv4-web.ts +79 -0
  306. package/src/registry/edge/token-source.test.ts +258 -0
  307. package/src/registry/edge/token-source.ts +169 -0
  308. package/src/registry/edge/worker.ts +61 -0
  309. package/src/registry/handler.ts +89 -0
  310. package/src/registry/headers-file.ts +61 -0
  311. package/src/registry/index.ts +31 -0
  312. package/src/registry/mirror-paths.ts +10 -0
  313. package/src/registry/mirror.test.ts +198 -0
  314. package/src/registry/mirror.ts +395 -0
  315. package/src/registry/proxies.test.ts +153 -0
  316. package/src/registry/proxies.ts +132 -0
  317. package/src/registry/reader.ts +225 -0
  318. package/src/registry/registry.test.ts +262 -0
  319. package/src/registry/semver.ts +34 -0
  320. package/src/registry/serve.ts +82 -0
  321. package/src/registry/source-forms.test.ts +87 -0
  322. package/src/registry/source-forms.ts +216 -0
  323. package/src/registry/targets/artifactory.test.ts +248 -0
  324. package/src/registry/targets/artifactory.ts +257 -0
  325. package/src/registry/targets/fake-artifactory.ts +158 -0
  326. package/src/registry/targets/fake-gitlab.ts +161 -0
  327. package/src/registry/targets/fake-hcp.ts +233 -0
  328. package/src/registry/targets/fake-http.ts +97 -0
  329. package/src/registry/targets/fake-nexus.ts +152 -0
  330. package/src/registry/targets/gitlab.test.ts +208 -0
  331. package/src/registry/targets/gitlab.ts +223 -0
  332. package/src/registry/targets/hcp.test.ts +230 -0
  333. package/src/registry/targets/hcp.ts +360 -0
  334. package/src/registry/targets/hosted.ts +117 -0
  335. package/src/registry/targets/index.ts +17 -0
  336. package/src/registry/targets/module-target.ts +131 -0
  337. package/src/registry/targets/nexus.test.ts +220 -0
  338. package/src/registry/targets/nexus.ts +314 -0
  339. package/src/registry/targets/zip.test.ts +65 -0
  340. package/src/registry/targets/zip.ts +287 -0
  341. package/src/registry/writer.ts +155 -0
  342. package/src/state/azure-blob.ts +6 -221
  343. package/src/state/gcs.ts +5 -252
  344. package/src/state/object-store.ts +4 -32
  345. package/src/state/s3.ts +10 -182
  346. package/src/state/store.ts +2 -2
  347. package/src/state-ops.test.ts +182 -0
  348. package/src/state-ops.ts +462 -0
  349. package/src/state-reads.ts +116 -0
  350. package/src/used-sources.test.ts +75 -0
  351. package/src/used-sources.ts +204 -0
@@ -0,0 +1,258 @@
1
+ // Lambda@Edge tokens from SSM Parameter Store or Secrets Manager (chant#4590), tested as a handler in
2
+ // process with a fake fetch standing in for both AWS APIs: no AWS call, real or emulated, and no deploy.
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { signV4 } from "@intentius/chant/cloud-lister/sigv4";
5
+ import { downloadDoc } from "../index";
6
+ import {
7
+ FAILED_READ_BACKOFF_MS,
8
+ lambdaEdgeHandler,
9
+ parseStoredTokens,
10
+ signV4Web,
11
+ type CloudFrontRequestEvent,
12
+ type EdgeAwsFetch,
13
+ type RegistryEdgeOptions,
14
+ } from "./index";
15
+
16
+ const HOST = "modules.test";
17
+ const DIR = "v1/modules/acme/net/generic";
18
+ const TARBALL = `https://${HOST}/${DIR}/0.1.0/net-0.1.0.tar.gz`;
19
+ const credentials = { accessKeyId: "AKIDEXAMPLE", secretAccessKey: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", sessionToken: "session" };
20
+ const T0 = 1_800_000_000_000;
21
+
22
+ const objects = new Map<string, string>([[`${DIR}/0.1.0/download`, downloadDoc(TARBALL)]]);
23
+ const files: RegistryEdgeOptions["files"] = async (key) => ({ body: objects.get(key) });
24
+
25
+ const event = (uri: string, token?: string): CloudFrontRequestEvent => ({
26
+ Records: [{ cf: { request: { uri, querystring: "", method: "GET", headers: { host: [{ key: "Host", value: HOST }], ...(token ? { authorization: [{ key: "Authorization", value: `Bearer ${token}` }] } : {}) } } } }],
27
+ });
28
+
29
+ const status = (answer: unknown): string | undefined => (answer && typeof answer === "object" && "status" in answer ? String((answer as { status: string }).status) : undefined);
30
+ const passedOn = (answer: unknown): boolean => !!answer && typeof answer === "object" && "uri" in answer;
31
+
32
+ /**
33
+ * Waits for the read the handler starts when it is made. It signs with WebCrypto before it calls
34
+ * fetch, and that digest settles off the microtask queue, so the call lands a moment after.
35
+ */
36
+ const firstRead = (calls: Call[]): Promise<void> => vi.waitFor(() => expect(calls.length).toBeGreaterThan(0), { timeout: 2_000, interval: 5 });
37
+
38
+ interface Call {
39
+ url: string;
40
+ headers: Record<string, string>;
41
+ body: string;
42
+ }
43
+
44
+ /** A fake SSM and Secrets Manager: answers from `values`, records each call, fails while `down` is set. */
45
+ function fakeAws(values: { ssm?: Record<string, string>; secrets?: Record<string, string> }) {
46
+ const calls: Call[] = [];
47
+ const state = { down: false as false | "network" | number };
48
+ const fetch: EdgeAwsFetch = async (url, init) => {
49
+ calls.push({ url, headers: init.headers, body: init.body });
50
+ if (state.down === "network") throw new Error("connect ETIMEDOUT");
51
+ const reply = (code: number, doc: unknown) => ({ ok: code < 300, status: code, text: async () => JSON.stringify(doc) });
52
+ if (typeof state.down === "number") return reply(state.down, { __type: "com.amazonaws#ThrottlingException" });
53
+ const target = init.headers["x-amz-target"];
54
+ const req = JSON.parse(init.body) as { Name?: string; SecretId?: string; WithDecryption?: boolean };
55
+ if (target === "AmazonSSM.GetParameter") {
56
+ const value = values.ssm?.[req.Name!];
57
+ return value === undefined ? reply(400, { __type: "ParameterNotFound" }) : reply(200, { Parameter: { Name: req.Name, Type: "SecureString", Value: value } });
58
+ }
59
+ if (target === "secretsmanager.GetSecretValue") {
60
+ const value = values.secrets?.[req.SecretId!];
61
+ return value === undefined ? reply(400, { __type: "ResourceNotFoundException" }) : reply(200, { Name: req.SecretId, SecretString: value });
62
+ }
63
+ return reply(400, { __type: "UnknownOperationException" });
64
+ };
65
+ return { fetch, calls, state, values };
66
+ }
67
+
68
+ describe("tokens in the zip (the default source)", () => {
69
+ it("keeps auth.tokens working with no store read", async () => {
70
+ const aws = fakeAws({});
71
+ const handler = lambdaEdgeHandler({ files, auth: { tokens: ["zip-token"] }, now: () => T0 });
72
+ expect(status(await handler(event(`/${DIR}/versions`)))).toBe("401");
73
+ expect(passedOn(await handler(event(`/${DIR}/versions`, "zip-token")))).toBe(true);
74
+ const explicit = lambdaEdgeHandler({ files, auth: { tokens: ["zip-token"], source: { kind: "zip" } }, now: () => T0 });
75
+ expect(passedOn(await explicit(event(`/${DIR}/versions`, "zip-token")))).toBe(true);
76
+ expect(aws.calls).toEqual([]);
77
+ });
78
+ });
79
+
80
+ describe("tokens from SSM Parameter Store", () => {
81
+ it("reads the parameter at cold start with a signed GetParameter call, and checks requests against it", async () => {
82
+ const aws = fakeAws({ ssm: { "/registry/tokens": "old-token,new-token" } });
83
+ const handler = lambdaEdgeHandler({ files, auth: { source: { kind: "ssm", name: "/registry/tokens", region: "us-east-1", credentials, fetch: aws.fetch } }, now: () => T0 });
84
+ // The read starts when the handler is made, before any request.
85
+ await firstRead(aws.calls);
86
+ expect(aws.calls).toHaveLength(1);
87
+ const call = aws.calls[0]!;
88
+ expect(call.url).toBe("https://ssm.us-east-1.amazonaws.com/");
89
+ expect(JSON.parse(call.body)).toEqual({ Name: "/registry/tokens", WithDecryption: true });
90
+ expect(call.headers["x-amz-target"]).toBe("AmazonSSM.GetParameter");
91
+ expect(call.headers["x-amz-security-token"]).toBe("session");
92
+ expect(call.headers.authorization).toMatch(/^AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE\/\d{8}\/us-east-1\/ssm\/aws4_request, SignedHeaders=content-type;host;x-amz-date;x-amz-security-token;x-amz-target, Signature=[0-9a-f]{64}$/);
93
+
94
+ expect(status(await handler(event(`/${DIR}/versions`)))).toBe("401");
95
+ expect(status(await handler(event(`/${DIR}/versions`, "wrong")))).toBe("401");
96
+ expect(passedOn(await handler(event(`/${DIR}/versions`, "old-token")))).toBe(true);
97
+ expect(passedOn(await handler(event(`/${DIR}/versions`, "new-token")))).toBe(true);
98
+ // Within the cache time the parameter is not read again.
99
+ expect(aws.calls).toHaveLength(1);
100
+ });
101
+
102
+ it("picks up a rotated parameter after cacheSeconds, with no redeploy", async () => {
103
+ let now = T0;
104
+ const aws = fakeAws({ ssm: { "/registry/tokens": "old-token" } });
105
+ const handler = lambdaEdgeHandler({ files, auth: { source: { kind: "ssm", name: "/registry/tokens", cacheSeconds: 60, credentials, fetch: aws.fetch } }, now: () => now });
106
+ expect(passedOn(await handler(event(`/${DIR}/versions`, "old-token")))).toBe(true);
107
+
108
+ aws.values.ssm!["/registry/tokens"] = "new-token";
109
+ now += 30_000;
110
+ // Still the cached read: the old token holds until the cache runs out, and never longer.
111
+ expect(passedOn(await handler(event(`/${DIR}/versions`, "old-token")))).toBe(true);
112
+ now += 31_000;
113
+ expect(status(await handler(event(`/${DIR}/versions`, "old-token")))).toBe("401");
114
+ expect(passedOn(await handler(event(`/${DIR}/versions`, "new-token")))).toBe(true);
115
+ expect(aws.calls).toHaveLength(2);
116
+ });
117
+
118
+ it("caps the cache time at an hour", async () => {
119
+ let now = T0;
120
+ const aws = fakeAws({ ssm: { p: "t" } });
121
+ const handler = lambdaEdgeHandler({ files, auth: { source: { kind: "ssm", name: "p", cacheSeconds: 86_400, credentials, fetch: aws.fetch } }, now: () => now });
122
+ await handler(event(`/${DIR}/versions`, "t"));
123
+ now += 3_600_001;
124
+ await handler(event(`/${DIR}/versions`, "t"));
125
+ expect(aws.calls).toHaveLength(2);
126
+ });
127
+
128
+ it("shares one read between requests that arrive while it is in flight", async () => {
129
+ const aws = fakeAws({ ssm: { p: "t" } });
130
+ const handler = lambdaEdgeHandler({ files, auth: { source: { kind: "ssm", name: "p", credentials, fetch: aws.fetch } }, now: () => T0 });
131
+ const answers = await Promise.all([1, 2, 3].map(() => handler(event(`/${DIR}/versions`, "t"))));
132
+ expect(answers.every(passedOn)).toBe(true);
133
+ expect(aws.calls).toHaveLength(1);
134
+ });
135
+
136
+ it("reads the region it is given, never the replica's", async () => {
137
+ const aws = fakeAws({ ssm: { p: "t" } });
138
+ lambdaEdgeHandler({ files, auth: { source: { kind: "ssm", name: "p", region: "eu-west-1", credentials, fetch: aws.fetch } } });
139
+ await firstRead(aws.calls);
140
+ expect(aws.calls[0]!.url).toBe("https://ssm.eu-west-1.amazonaws.com/");
141
+ expect(aws.calls[0]!.headers.authorization).toContain("/eu-west-1/ssm/aws4_request");
142
+ });
143
+ });
144
+
145
+ describe("tokens from Secrets Manager", () => {
146
+ it("reads SecretString with a signed GetSecretValue call, JSON form with a signing key", async () => {
147
+ const aws = fakeAws({ secrets: { "registry/tokens": JSON.stringify({ tokens: ["s3cret"], signingKey: "sign-key" }) } });
148
+ const handler = lambdaEdgeHandler({ files, auth: { source: { kind: "secrets-manager", secretId: "registry/tokens", credentials, fetch: aws.fetch } }, now: () => T0 });
149
+ await firstRead(aws.calls);
150
+ const call = aws.calls[0]!;
151
+ expect(call.url).toBe("https://secretsmanager.us-east-1.amazonaws.com/");
152
+ expect(call.headers["x-amz-target"]).toBe("secretsmanager.GetSecretValue");
153
+ expect(JSON.parse(call.body)).toEqual({ SecretId: "registry/tokens" });
154
+ expect(call.headers.authorization).toContain("/us-east-1/secretsmanager/aws4_request");
155
+
156
+ expect(status(await handler(event(`/${DIR}/versions`)))).toBe("401");
157
+ const download = await handler(event(`/${DIR}/0.1.0/download`, "s3cret"));
158
+ expect(status(download)).toBe("200");
159
+ const location = new URL((download as { headers: Record<string, { value: string }[]> }).headers["x-terraform-get"]![0]!.value);
160
+ expect(location.searchParams.get("signature")).toBeTruthy();
161
+ });
162
+
163
+ it("takes a plain-text secret, one token per line", async () => {
164
+ const aws = fakeAws({ secrets: { s: "a-token\nb-token\n" } });
165
+ const handler = lambdaEdgeHandler({ files, auth: { source: { kind: "secrets-manager", secretId: "s", credentials, fetch: aws.fetch } }, now: () => T0 });
166
+ expect(passedOn(await handler(event(`/${DIR}/versions`, "b-token")))).toBe(true);
167
+ });
168
+
169
+ it("takes credentials from a function, such as a role the caller assumes", async () => {
170
+ const aws = fakeAws({ secrets: { s: "t" } });
171
+ const handler = lambdaEdgeHandler({ files, auth: { source: { kind: "secrets-manager", secretId: "s", credentials: async () => ({ accessKeyId: "AKIDFN", secretAccessKey: "x" }), fetch: aws.fetch } }, now: () => T0 });
172
+ expect(passedOn(await handler(event(`/${DIR}/versions`, "t")))).toBe(true);
173
+ expect(aws.calls[0]!.headers.authorization).toContain("Credential=AKIDFN/");
174
+ expect(aws.calls[0]!.headers["x-amz-security-token"]).toBeUndefined();
175
+ });
176
+ });
177
+
178
+ describe("fails closed", () => {
179
+ it("answers 503 and never passes a request on while the store cannot be reached", async () => {
180
+ const aws = fakeAws({ ssm: { p: "t" } });
181
+ aws.state.down = "network";
182
+ const log: string[] = [];
183
+ const handler = lambdaEdgeHandler({ files, auth: { source: { kind: "ssm", name: "p", credentials, fetch: aws.fetch } }, now: () => T0, log: (l) => log.push(l) });
184
+ const answer = await handler(event(`/${DIR}/versions`, "t"));
185
+ expect(status(answer)).toBe("503");
186
+ expect((answer as { headers: Record<string, { value: string }[]> }).headers["cache-control"]![0]!.value).toBe("no-store");
187
+ expect(status(await handler(event(`/${DIR}/0.1.0/download`, "t")))).toBe("503");
188
+ expect(log.join("\n")).toContain("token store:");
189
+ expect(log.join("\n")).not.toContain("Bearer");
190
+ });
191
+
192
+ it("refuses on an AWS error answer, a missing parameter, or a value with no token", async () => {
193
+ for (const [values, down] of [[{ ssm: { p: "t" } }, 400], [{ ssm: {} }, false], [{ ssm: { p: " ,\n " } }, false], [{ ssm: { p: "{not json" } }, false]] as const) {
194
+ const aws = fakeAws(values as { ssm: Record<string, string> });
195
+ aws.state.down = down;
196
+ const handler = lambdaEdgeHandler({ files, auth: { source: { kind: "ssm", name: "p", credentials, fetch: aws.fetch } }, now: () => T0 });
197
+ expect(status(await handler(event(`/${DIR}/versions`, "t")))).toBe("503");
198
+ }
199
+ });
200
+
201
+ it("drops the cached tokens when a refresh fails, then recovers after the backoff", async () => {
202
+ let now = T0;
203
+ const aws = fakeAws({ ssm: { p: "t" } });
204
+ const handler = lambdaEdgeHandler({ files, auth: { source: { kind: "ssm", name: "p", cacheSeconds: 60, credentials, fetch: aws.fetch } }, now: () => now });
205
+ expect(passedOn(await handler(event(`/${DIR}/versions`, "t")))).toBe(true);
206
+ aws.state.down = 500;
207
+ now += 61_000;
208
+ expect(status(await handler(event(`/${DIR}/versions`, "t")))).toBe("503");
209
+ // Within the backoff the store is not read again on every request.
210
+ expect(status(await handler(event(`/${DIR}/versions`, "t")))).toBe("503");
211
+ expect(aws.calls).toHaveLength(2);
212
+ aws.state.down = false;
213
+ now += FAILED_READ_BACKOFF_MS + 1;
214
+ expect(passedOn(await handler(event(`/${DIR}/versions`, "t")))).toBe(true);
215
+ expect(aws.calls).toHaveLength(3);
216
+ });
217
+
218
+ it("refuses when there are no credentials in the runtime", async () => {
219
+ const aws = fakeAws({ ssm: { p: "t" } });
220
+ const env = (globalThis as { process?: { env: Record<string, string | undefined> } }).process!.env;
221
+ const saved = { id: env.AWS_ACCESS_KEY_ID, secret: env.AWS_SECRET_ACCESS_KEY };
222
+ delete env.AWS_ACCESS_KEY_ID;
223
+ delete env.AWS_SECRET_ACCESS_KEY;
224
+ try {
225
+ const handler = lambdaEdgeHandler({ files, auth: { source: { kind: "ssm", name: "p", fetch: aws.fetch } }, now: () => T0 });
226
+ expect(status(await handler(event(`/${DIR}/versions`, "t")))).toBe("503");
227
+ expect(aws.calls).toEqual([]);
228
+ } finally {
229
+ if (saved.id !== undefined) env.AWS_ACCESS_KEY_ID = saved.id;
230
+ if (saved.secret !== undefined) env.AWS_SECRET_ACCESS_KEY = saved.secret;
231
+ }
232
+ });
233
+ });
234
+
235
+ describe("parseStoredTokens", () => {
236
+ it("reads the line and comma form and the JSON form", () => {
237
+ expect(parseStoredTokens("a, b\nc")).toEqual({ tokens: ["a", "b", "c"] });
238
+ expect(parseStoredTokens('{"tokens":"a,b"}')).toEqual({ tokens: ["a", "b"] });
239
+ expect(parseStoredTokens('{"tokens":["a"," b "],"signingKey":"k"}')).toEqual({ tokens: ["a", "b"], signingKey: "k" });
240
+ expect(() => parseStoredTokens("{oops")).toThrow("do not parse");
241
+ });
242
+ });
243
+
244
+ describe("signV4Web", () => {
245
+ it("signs exactly as chant core's signer does", async () => {
246
+ const input = {
247
+ method: "POST",
248
+ url: "https://ssm.us-east-1.amazonaws.com/",
249
+ headers: { "content-type": "application/x-amz-json-1.1", "x-amz-target": "AmazonSSM.GetParameter" },
250
+ body: JSON.stringify({ Name: "/registry/tokens", WithDecryption: true }),
251
+ region: "us-east-1",
252
+ service: "ssm",
253
+ credentials,
254
+ now: new Date(T0),
255
+ };
256
+ expect(await signV4Web(input)).toEqual(signV4(input));
257
+ });
258
+ });
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Where the Lambda@Edge function gets its accepted tokens (chant#4590).
3
+ * Lambda@Edge has no environment of its own, so by default the tokens are
4
+ * built into the zip (`source: { kind: "zip" }`, terragucci#1287's
5
+ * registry-edge.json). The function can instead read them from SSM Parameter
6
+ * Store or Secrets Manager: the read starts when the handler is made (the
7
+ * cold start), its answer is kept for `cacheSeconds`, and a rotation in the
8
+ * store reaches every replica within that time with no redeploy.
9
+ *
10
+ * The read fails closed: while the store cannot be read, or holds no token,
11
+ * every request gets a 503 and none reaches the bucket.
12
+ *
13
+ * The reads are plain fetch calls signed with ./sigv4-web.ts (Web Crypto),
14
+ * so registry/edge stays free of Node built-ins (chant#4589). Credentials
15
+ * default to the execution role's, which the Lambda runtime puts in
16
+ * AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_SESSION_TOKEN; the role
17
+ * needs ssm:GetParameter (and kms:Decrypt for a SecureString under a key of
18
+ * your own) or secretsmanager:GetSecretValue on that one name.
19
+ */
20
+ import { tokensFromSecret } from "./edge";
21
+ import { signV4Web, type EdgeAwsCredentials } from "./sigv4-web";
22
+
23
+ /** The fetch the reads call; a test passes a fake, so no AWS call is made. */
24
+ export type EdgeAwsFetch = (url: string, init: { method: string; headers: Record<string, string>; body: string }) => Promise<{ ok: boolean; status: number; text(): Promise<string> }>;
25
+
26
+ /** What a store read shares. */
27
+ interface StoreSourceBase {
28
+ /**
29
+ * The region the parameter or secret is in. Default us-east-1, where a
30
+ * Lambda@Edge function is created; the replicas run in other regions, so
31
+ * the function's own AWS_REGION is never used.
32
+ */
33
+ region?: string;
34
+ /** Seconds a read is kept before the next request reads again. Default 300, at most 3600. */
35
+ cacheSeconds?: number;
36
+ /** The credentials to sign with. Default: the execution role's, from the runtime's environment. */
37
+ credentials?: EdgeAwsCredentials | (() => Promise<EdgeAwsCredentials>);
38
+ /** The fetch to call. Default the global fetch. */
39
+ fetch?: EdgeAwsFetch;
40
+ }
41
+
42
+ /** An SSM parameter (String or SecureString) holding the tokens. */
43
+ export interface SsmTokenSource extends StoreSourceBase {
44
+ kind: "ssm";
45
+ /** The parameter's name, such as /registry/tokens. */
46
+ name: string;
47
+ }
48
+
49
+ /** A Secrets Manager secret whose SecretString holds the tokens. */
50
+ export interface SecretsManagerTokenSource extends StoreSourceBase {
51
+ kind: "secrets-manager";
52
+ /** The secret's name or ARN. */
53
+ secretId: string;
54
+ }
55
+
56
+ /** The tokens built into the function: `auth.tokens`. */
57
+ export interface ZipTokenSource {
58
+ kind: "zip";
59
+ }
60
+
61
+ export type EdgeTokenSource = ZipTokenSource | SsmTokenSource | SecretsManagerTokenSource;
62
+
63
+ /** The tokens a store holds, and the signing key when the store names one. */
64
+ export interface StoredTokens {
65
+ tokens: string[];
66
+ signingKey?: string;
67
+ }
68
+
69
+ export const DEFAULT_TOKEN_CACHE_SECONDS = 300;
70
+ export const MAX_TOKEN_CACHE_SECONDS = 3600;
71
+ /** After a failed read, requests are refused for this long before the next read, so a broken store is not read once per request. */
72
+ export const FAILED_READ_BACKOFF_MS = 5_000;
73
+
74
+ /**
75
+ * A stored value as tokens. Either the worker's REGISTRY_TOKENS form (one per
76
+ * line or comma separated) or JSON: `{ "tokens": [...] | "a,b", "signingKey": "..." }`.
77
+ */
78
+ export function parseStoredTokens(text: string): StoredTokens {
79
+ const trimmed = text.trim();
80
+ if (trimmed.startsWith("{")) {
81
+ let doc: { tokens?: unknown; signingKey?: unknown };
82
+ try {
83
+ doc = JSON.parse(trimmed);
84
+ } catch {
85
+ throw new Error("the stored tokens look like JSON but do not parse");
86
+ }
87
+ const tokens = Array.isArray(doc.tokens) ? doc.tokens.filter((t): t is string => typeof t === "string").map((t) => t.trim()).filter((t) => !!t) : typeof doc.tokens === "string" ? tokensFromSecret(doc.tokens) : [];
88
+ return { tokens, ...(typeof doc.signingKey === "string" && doc.signingKey ? { signingKey: doc.signingKey } : {}) };
89
+ }
90
+ return { tokens: tokensFromSecret(trimmed) };
91
+ }
92
+
93
+ /** The execution role's credentials, read from the runtime's environment without importing node:process. */
94
+ function runtimeCredentials(): EdgeAwsCredentials {
95
+ const env = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env ?? {};
96
+ const accessKeyId = env.AWS_ACCESS_KEY_ID;
97
+ const secretAccessKey = env.AWS_SECRET_ACCESS_KEY;
98
+ if (!accessKeyId || !secretAccessKey) throw new Error("no AWS credentials in the function's runtime (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)");
99
+ return { accessKeyId, secretAccessKey, ...(env.AWS_SESSION_TOKEN ? { sessionToken: env.AWS_SESSION_TOKEN } : {}) };
100
+ }
101
+
102
+ const defaultFetch: EdgeAwsFetch = (url, init) => fetch(url, init);
103
+
104
+ /** One read of the store: the signed JSON call, and the value it answers. */
105
+ export async function readStoredTokens(source: SsmTokenSource | SecretsManagerTokenSource, now: Date = new Date()): Promise<StoredTokens> {
106
+ const region = source.region ?? "us-east-1";
107
+ const service = source.kind === "ssm" ? "ssm" : "secretsmanager";
108
+ const url = `https://${service}.${region}.amazonaws.com/`;
109
+ const target = source.kind === "ssm" ? "AmazonSSM.GetParameter" : "secretsmanager.GetSecretValue";
110
+ const body = JSON.stringify(source.kind === "ssm" ? { Name: source.name, WithDecryption: true } : { SecretId: source.secretId });
111
+ const credentials = typeof source.credentials === "function" ? await source.credentials() : (source.credentials ?? runtimeCredentials());
112
+ const headers = await signV4Web({ method: "POST", url, headers: { "content-type": "application/x-amz-json-1.1", "x-amz-target": target }, body, region, service, credentials, now });
113
+ const res = await (source.fetch ?? defaultFetch)(url, { method: "POST", headers, body });
114
+ const text = await res.text();
115
+ const what = source.kind === "ssm" ? `SSM parameter ${source.name}` : `secret ${source.secretId}`;
116
+ if (!res.ok) {
117
+ let type = "";
118
+ try {
119
+ type = String((JSON.parse(text) as { __type?: unknown }).__type ?? "").split("#").pop() ?? "";
120
+ } catch {}
121
+ throw new Error(`reading ${what} in ${region} answered ${res.status}${type ? ` ${type}` : ""}`);
122
+ }
123
+ let value: unknown;
124
+ try {
125
+ const doc = JSON.parse(text) as { Parameter?: { Value?: unknown }; SecretString?: unknown };
126
+ value = source.kind === "ssm" ? doc.Parameter?.Value : doc.SecretString;
127
+ } catch {
128
+ throw new Error(`reading ${what} answered something other than JSON`);
129
+ }
130
+ if (typeof value !== "string") throw new Error(`${what} has no text value${source.kind === "secrets-manager" ? " (a binary secret is not read)" : ""}`);
131
+ const stored = parseStoredTokens(value);
132
+ if (stored.tokens.length === 0) throw new Error(`${what} holds no token`);
133
+ return stored;
134
+ }
135
+
136
+ /** Tokens read from a store and kept for a bounded time. `get` throws while the store cannot be read. */
137
+ export interface TokenCache {
138
+ get(): Promise<StoredTokens>;
139
+ }
140
+
141
+ /**
142
+ * A cache over `readStoredTokens`. The first read starts at once (cold
143
+ * start). Requests that arrive during a read wait on that one read. A good
144
+ * read is kept for `cacheSeconds`; a failed one is kept for
145
+ * FAILED_READ_BACKOFF_MS, and every `get` in that time throws. The last good
146
+ * tokens are never served past their time, so the function fails closed.
147
+ */
148
+ export function storedTokenCache(source: SsmTokenSource | SecretsManagerTokenSource, now: () => number = Date.now): TokenCache {
149
+ const cacheMs = Math.min(Math.max(source.cacheSeconds ?? DEFAULT_TOKEN_CACHE_SECONDS, 0), MAX_TOKEN_CACHE_SECONDS) * 1000;
150
+ let held: { until: number; result: Promise<StoredTokens> } | undefined;
151
+ const read = (): Promise<StoredTokens> => {
152
+ const result = readStoredTokens(source, new Date(now()));
153
+ const entry = { until: Number.POSITIVE_INFINITY, result };
154
+ held = entry;
155
+ result.then(
156
+ () => {
157
+ entry.until = now() + cacheMs;
158
+ },
159
+ () => {
160
+ entry.until = now() + FAILED_READ_BACKOFF_MS;
161
+ },
162
+ );
163
+ return result;
164
+ };
165
+ read().catch(() => {});
166
+ return {
167
+ get: () => (held && now() < held.until ? held.result : read()),
168
+ };
169
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The registry edge function as a Cloudflare Worker (chant#4566). Bindings:
3
+ *
4
+ * REGISTRY_BUCKET an R2 bucket binding holding the registry's files, or
5
+ * REGISTRY_ORIGIN the bucket's HTTP endpoint, when the files are elsewhere
6
+ * REGISTRY_PREFIX the registry's prefix in the bucket (optional)
7
+ * REGISTRY_AUTH "token" turns read auth on; anything else, or unset, leaves it off
8
+ * REGISTRY_TOKENS a secret: the accepted tokens, one per line or comma separated
9
+ * REGISTRY_SIGNING_KEY a secret: the key tarball URLs are signed with (default: the first token)
10
+ * REGISTRY_URL_TTL seconds a signed tarball URL works (default 300)
11
+ *
12
+ * Rotating a token: add the new one to REGISTRY_TOKENS beside the old, move
13
+ * the CLIs to it, then drop the old one. Rotating the signing key breaks only
14
+ * signed URLs already handed out, which live for REGISTRY_URL_TTL seconds.
15
+ *
16
+ * No Node APIs, so it runs without the nodejs_compat flag.
17
+ */
18
+ import type { RegistryFiles } from "../handler";
19
+ import { DEFAULT_SIGNED_TTL, originFiles, registryEdge, tokensFromSecret, type EdgeAuth } from "./edge";
20
+
21
+ /** The part of an R2 bucket binding the worker uses. */
22
+ export interface R2BucketLike {
23
+ get(key: string): Promise<{ arrayBuffer(): Promise<ArrayBuffer> } | null>;
24
+ }
25
+
26
+ export interface RegistryWorkerEnv {
27
+ REGISTRY_BUCKET?: R2BucketLike;
28
+ REGISTRY_ORIGIN?: string;
29
+ REGISTRY_PREFIX?: string;
30
+ REGISTRY_AUTH?: string;
31
+ REGISTRY_TOKENS?: string;
32
+ REGISTRY_SIGNING_KEY?: string;
33
+ REGISTRY_URL_TTL?: string;
34
+ }
35
+
36
+ /** Files read from an R2 binding. */
37
+ export const r2Files = (bucket: R2BucketLike): RegistryFiles => async (key) => {
38
+ const o = await bucket.get(key);
39
+ return o ? { body: new Uint8Array(await o.arrayBuffer()) } : {};
40
+ };
41
+
42
+ /** Read auth from the worker's bindings, or undefined when it is off. */
43
+ export function workerAuth(env: RegistryWorkerEnv): EdgeAuth | undefined {
44
+ if ((env.REGISTRY_AUTH ?? "").trim().toLowerCase() !== "token") return undefined;
45
+ const ttl = Number(env.REGISTRY_URL_TTL);
46
+ return {
47
+ tokens: tokensFromSecret(env.REGISTRY_TOKENS),
48
+ ...(env.REGISTRY_SIGNING_KEY ? { signingKey: env.REGISTRY_SIGNING_KEY } : {}),
49
+ ttl: Number.isInteger(ttl) && ttl > 0 ? ttl : DEFAULT_SIGNED_TTL,
50
+ };
51
+ }
52
+
53
+ /** Answers one request with the worker's bindings. */
54
+ export async function registryWorkerFetch(request: Request, env: RegistryWorkerEnv): Promise<Response> {
55
+ const files = env.REGISTRY_BUCKET ? r2Files(env.REGISTRY_BUCKET) : env.REGISTRY_ORIGIN ? originFiles(env.REGISTRY_ORIGIN) : undefined;
56
+ if (!files) return new Response("the registry worker needs a REGISTRY_BUCKET binding or REGISTRY_ORIGIN\n", { status: 500 });
57
+ return registryEdge({ files, prefix: env.REGISTRY_PREFIX, auth: workerAuth(env) })(request);
58
+ }
59
+
60
+ /** The module a worker's entry file re-exports as its default. */
61
+ export const registryWorker = { fetch: registryWorkerFetch };
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Answering registry requests as the CLIs need them: each file as stored,
3
+ * except a version's `download`, which goes out as `downloadAnswer` (200, the
4
+ * JSON body, and `X-Terraform-Get` from the body's location; terragucci#1287).
5
+ *
6
+ * `registryHandler` is a web-standard `(Request) => Response` with no Node
7
+ * imports, so the same code answers in an edge function (chant#4566), in a
8
+ * test as a fetch (`handlerFetch`), and on a loopback socket through
9
+ * `serveRegistry` in ./serve, which real `terraform init` and `tofu init`
10
+ * runs point at.
11
+ */
12
+ import { keyFor } from "./address";
13
+ import type { RegistryFetch } from "./reader";
14
+ import { downloadAnswer, downloadLocation, isDownloadPath } from "./writer";
15
+
16
+ /** Where the handler reads the registry's files: a key in, the bytes out (or none). */
17
+ export type RegistryFiles = (key: string) => Promise<{ body?: string | Uint8Array }>;
18
+
19
+ /** A registry held by a store with a text `read` (a bucket client, a test's map). Tarballs need a store that reads bytes. */
20
+ export const storeFiles = (store: { read(key: string): Promise<{ body?: string | Uint8Array }> }): RegistryFiles => (key) => store.read(key);
21
+
22
+ export interface RegistryHandlerOptions {
23
+ /** The registry's prefix in its store: URL path `p` reads key `<prefix>/p`. */
24
+ prefix?: string;
25
+ /** Called once per request, for a test's log: method, path and the status sent. */
26
+ log?: (line: string) => void;
27
+ }
28
+
29
+ export type RegistryHandler = (request: Request) => Promise<Response>;
30
+
31
+ /**
32
+ * The pages a release catalog writes beside the registry (chant#4599,
33
+ * `<prefix>/catalog/`), by extension, so a browser renders them instead of
34
+ * downloading them.
35
+ */
36
+ const PAGE_TYPES: Record<string, string> = {
37
+ ".html": "text/html; charset=utf-8",
38
+ ".css": "text/css; charset=utf-8",
39
+ ".js": "text/javascript; charset=utf-8",
40
+ ".svg": "image/svg+xml",
41
+ };
42
+
43
+ /** The `content-type` a registry file is served with. */
44
+ export const registryContentType = (key: string): string => {
45
+ if (key.endsWith(".tar.gz") || key.endsWith(".tgz")) return "application/gzip";
46
+ if (key.endsWith(".json") || /(^|\/)(versions|download)$/.test(key)) return "application/json";
47
+ const dot = key.lastIndexOf(".");
48
+ return (dot > key.lastIndexOf("/") && PAGE_TYPES[key.slice(dot).toLowerCase()]) || "application/octet-stream";
49
+ };
50
+
51
+ // TextEncoder and TextDecoder, not Buffer: an edge runtime (a Cloudflare Worker without nodejs_compat) has no Buffer.
52
+ const asText = (body: string | Uint8Array): string => (typeof body === "string" ? body : new TextDecoder().decode(body));
53
+
54
+ /** Answers GET and HEAD for a registry's files, adding `X-Terraform-Get` to each download answer. */
55
+ export function registryHandler(files: RegistryFiles, options: RegistryHandlerOptions = {}): RegistryHandler {
56
+ return async (request) => {
57
+ const url = new URL(request.url);
58
+ const method = request.method.toUpperCase();
59
+ const done = (res: Response): Response => {
60
+ options.log?.(`${method} ${url.pathname} -> ${res.status}`);
61
+ return res;
62
+ };
63
+ if (method !== "GET" && method !== "HEAD") return done(new Response(null, { status: 405, headers: { allow: "GET, HEAD" } }));
64
+ let path: string;
65
+ try {
66
+ path = decodeURIComponent(url.pathname).replace(/^\/+/, "");
67
+ } catch {
68
+ return done(new Response(null, { status: 400 }));
69
+ }
70
+ if (!path || path.split("/").includes("..")) return done(new Response(null, { status: 404 }));
71
+ const key = keyFor(options.prefix, path);
72
+ const { body } = await files(key);
73
+ if (body === undefined) return done(new Response(null, { status: 404 }));
74
+ if (isDownloadPath(path)) {
75
+ const location = downloadLocation(asText(body));
76
+ if (location) {
77
+ const answer = downloadAnswer(location);
78
+ return done(new Response(method === "HEAD" ? null : answer.body, { status: answer.status, headers: answer.headers }));
79
+ }
80
+ }
81
+ const bytes = typeof body === "string" ? new TextEncoder().encode(body) : body;
82
+ const headers = { "content-type": registryContentType(key), "content-length": String(bytes.byteLength) };
83
+ return done(new Response(method === "HEAD" ? null : new Uint8Array(bytes), { status: 200, headers }));
84
+ };
85
+ }
86
+
87
+ /** A fetch that answers from a handler in process, for tests that need no socket. */
88
+ export const handlerFetch = (handler: RegistryHandler): RegistryFetch => (url, init) => handler(new Request(url, { method: init?.method ?? "GET", ...(init?.headers ? { headers: init.headers } : {}) }));
89
+
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The `_headers` file Cloudflare Pages and Netlify read beside a static site
3
+ * (terragucci#1287): one rule per version's download path, sending
4
+ * `X-Terraform-Get` with the location the stored body names. A host that
5
+ * reads `_headers` needs no edge function for Terraform to install; a plain
6
+ * bucket, GitHub Pages and GitLab Pages can't set a header per object and do.
7
+ *
8
+ * Cloudflare Pages reads at most 100 rules from `_headers`; a registry with
9
+ * more versions than that needs the edge function there.
10
+ */
11
+ import { DOWNLOAD_HEADER } from "./writer";
12
+
13
+ /** The file's name at the site's root. */
14
+ export const HEADERS_FILE = "_headers";
15
+ /** Rules Cloudflare Pages reads from `_headers`. */
16
+ export const PAGES_HEADER_RULES = 100;
17
+
18
+ /** One download path (from the site root, leading `/`) and the location its header names. */
19
+ export interface DownloadHeaderRule {
20
+ path: string;
21
+ location: string;
22
+ }
23
+
24
+ const rulePath = (path: string): string => `/${path.replace(/^\/+/, "")}`;
25
+
26
+ /** The download rules an existing `_headers` file holds; other rules are kept by `headersFile` as they are. */
27
+ export function readHeadersFile(text: string | undefined): { downloads: DownloadHeaderRule[]; other: string[] } {
28
+ const downloads: DownloadHeaderRule[] = [];
29
+ const other: string[] = [];
30
+ let current: { path: string; lines: string[] } | undefined;
31
+ const flush = () => {
32
+ if (!current) return;
33
+ const header = current.lines.map((l) => /^\s+([^:]+):\s*(.*)$/.exec(l)).find((m) => m && m[1]!.trim().toLowerCase() === DOWNLOAD_HEADER.toLowerCase());
34
+ if (header && current.lines.length === 1) downloads.push({ path: current.path, location: header[2]!.trim() });
35
+ else other.push([current.path, ...current.lines].join("\n"));
36
+ current = undefined;
37
+ };
38
+ for (const line of (text ?? "").split(/\r?\n/)) {
39
+ if (!line.trim() || line.trim().startsWith("#")) continue;
40
+ if (/^\s/.test(line)) current?.lines.push(line);
41
+ else {
42
+ flush();
43
+ current = { path: line.trim(), lines: [] };
44
+ }
45
+ }
46
+ flush();
47
+ return { downloads, other };
48
+ }
49
+
50
+ /**
51
+ * A `_headers` file with a rule per download, merged into `existing`: rules
52
+ * for other paths stay as written, a download rule for the same path is
53
+ * replaced, and download rules are sorted by path.
54
+ */
55
+ export function headersFile(rules: DownloadHeaderRule[], existing?: string): string {
56
+ const held = readHeadersFile(existing);
57
+ const byPath = new Map(held.downloads.map((r) => [r.path, r.location]));
58
+ for (const r of rules) byPath.set(rulePath(r.path), r.location);
59
+ const downloads = [...byPath.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([path, location]) => `${path}\n ${DOWNLOAD_HEADER}: ${location}`);
60
+ return `${[...held.other, ...downloads].join("\n\n")}\n`;
61
+ }