@homeflare/alchemy 0.20.0 → 0.22.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 (191) hide show
  1. package/README.md +27 -0
  2. package/dist/caddy/access-forward-auth.d.ts +56 -0
  3. package/dist/caddy/access-forward-auth.d.ts.map +1 -0
  4. package/dist/caddy/index.d.ts +6 -0
  5. package/dist/caddy/index.d.ts.map +1 -1
  6. package/dist/caddy/index.js +169 -75
  7. package/dist/caddy/index.js.map +10 -7
  8. package/dist/cloudflare/mcp-server-api.d.ts +135 -0
  9. package/dist/cloudflare/mcp-server-api.d.ts.map +1 -0
  10. package/dist/cloudflare/mcp-server-form.d.ts +116 -0
  11. package/dist/cloudflare/mcp-server-form.d.ts.map +1 -0
  12. package/dist/index.d.ts +1 -0
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/launchd/file-converge.d.ts +70 -0
  15. package/dist/launchd/file-converge.d.ts.map +1 -0
  16. package/dist/launchd/file-identity.d.ts +32 -0
  17. package/dist/launchd/file-identity.d.ts.map +1 -0
  18. package/dist/launchd/host-file-form.d.ts +2 -0
  19. package/dist/launchd/host-file-form.d.ts.map +1 -1
  20. package/dist/launchd/host-file-lifecycle.d.ts +11 -24
  21. package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
  22. package/dist/launchd/host-file.d.ts.map +1 -1
  23. package/dist/launchd/index.d.ts +2 -0
  24. package/dist/launchd/index.d.ts.map +1 -1
  25. package/dist/launchd/index.js +225 -120
  26. package/dist/launchd/index.js.map +13 -10
  27. package/dist/launchd/local-runner.d.ts.map +1 -1
  28. package/dist/launchd/port-claims.d.ts +31 -0
  29. package/dist/launchd/port-claims.d.ts.map +1 -0
  30. package/dist/launchd/runner.d.ts +17 -2
  31. package/dist/launchd/runner.d.ts.map +1 -1
  32. package/dist/launchd/sudo-runner.d.ts +11 -0
  33. package/dist/launchd/sudo-runner.d.ts.map +1 -1
  34. package/dist/linux/index.js.map +3 -3
  35. package/dist/linux/sudo-listing.d.ts +47 -0
  36. package/dist/linux/sudo-listing.d.ts.map +1 -0
  37. package/dist/proxmox/credential-literals.d.ts +1 -1
  38. package/dist/proxmox/credential-literals.d.ts.map +1 -1
  39. package/dist/proxmox/generated/pbs/nodes-node.d.ts +1 -1
  40. package/dist/proxmox/generated/pbs/nodes-node.d.ts.map +1 -1
  41. package/dist/proxmox/generated/pbs.d.ts +7 -0
  42. package/dist/proxmox/generated/pbs.d.ts.map +1 -1
  43. package/dist/proxmox/generated/pve.d.ts +7 -0
  44. package/dist/proxmox/generated/pve.d.ts.map +1 -1
  45. package/dist/proxmox/index.d.ts +1 -1
  46. package/dist/proxmox/index.d.ts.map +1 -1
  47. package/dist/proxmox/index.js +2 -2
  48. package/dist/proxmox/index.js.map +9 -9
  49. package/dist/proxmox/pbs-notification-target-form.d.ts +1 -1
  50. package/dist/proxmox/pbs-notification-target-form.d.ts.map +1 -1
  51. package/dist/proxmox/pbs-notification-target.d.ts +1 -1
  52. package/dist/proxmox/pbs-notification-target.d.ts.map +1 -1
  53. package/dist/release/archive.d.ts +11 -0
  54. package/dist/release/archive.d.ts.map +1 -0
  55. package/dist/release/binary-claim.d.ts +53 -0
  56. package/dist/release/binary-claim.d.ts.map +1 -0
  57. package/dist/release/binary-diff.d.ts +38 -0
  58. package/dist/release/binary-diff.d.ts.map +1 -0
  59. package/dist/release/binary-form.d.ts +118 -0
  60. package/dist/release/binary-form.d.ts.map +1 -0
  61. package/dist/release/binary-lifecycle.d.ts +55 -0
  62. package/dist/release/binary-lifecycle.d.ts.map +1 -0
  63. package/dist/release/binary-read.d.ts +11 -0
  64. package/dist/release/binary-read.d.ts.map +1 -0
  65. package/dist/release/binary.d.ts +68 -0
  66. package/dist/release/binary.d.ts.map +1 -0
  67. package/dist/release/catalog.d.ts +113 -0
  68. package/dist/release/catalog.d.ts.map +1 -0
  69. package/dist/release/checksums.d.ts +22 -0
  70. package/dist/release/checksums.d.ts.map +1 -0
  71. package/dist/release/declared-pins.d.ts +4 -0
  72. package/dist/release/declared-pins.d.ts.map +1 -0
  73. package/dist/release/download.d.ts +44 -0
  74. package/dist/release/download.d.ts.map +1 -0
  75. package/dist/release/fake-deploy.d.ts +48 -0
  76. package/dist/release/fake-deploy.d.ts.map +1 -0
  77. package/dist/release/fake-engine.d.ts +8 -0
  78. package/dist/release/fake-engine.d.ts.map +1 -0
  79. package/dist/release/fake-install.d.ts +39 -0
  80. package/dist/release/fake-install.d.ts.map +1 -0
  81. package/dist/release/fake-provider.d.ts +58 -0
  82. package/dist/release/fake-provider.d.ts.map +1 -0
  83. package/dist/release/fake-release.d.ts +95 -0
  84. package/dist/release/fake-release.d.ts.map +1 -0
  85. package/dist/release/index.d.ts +25 -0
  86. package/dist/release/index.d.ts.map +1 -0
  87. package/dist/release/index.js +1425 -0
  88. package/dist/release/index.js.map +36 -0
  89. package/dist/release/openbao.d.ts +35 -0
  90. package/dist/release/openbao.d.ts.map +1 -0
  91. package/dist/release/providers.d.ts +19 -0
  92. package/dist/release/providers.d.ts.map +1 -0
  93. package/dist/release/refused.d.ts +39 -0
  94. package/dist/release/refused.d.ts.map +1 -0
  95. package/dist/release/tar.d.ts +14 -0
  96. package/dist/release/tar.d.ts.map +1 -0
  97. package/dist/release/victoria.d.ts +36 -0
  98. package/dist/release/victoria.d.ts.map +1 -0
  99. package/dist/{proxmox → secrets}/write-only.d.ts.map +1 -1
  100. package/docs/launchd-ports.md +109 -0
  101. package/docs/launchd.md +5 -0
  102. package/docs/release-binary-catalogs.md +138 -0
  103. package/docs/release-binary-measured.md +163 -0
  104. package/docs/release-binary-openbao.md +151 -0
  105. package/docs/release-binary-refusals.md +57 -0
  106. package/docs/release-binary-upstream.md +163 -0
  107. package/docs/release-binary.md +165 -0
  108. package/package.json +5 -1
  109. package/src/caddy/access-forward-auth.test.ts +63 -0
  110. package/src/caddy/access-forward-auth.ts +120 -0
  111. package/src/caddy/index.ts +10 -0
  112. package/src/caddy/providers.test.ts +4 -0
  113. package/src/cloudflare/mcp-server-api.ts +294 -0
  114. package/src/cloudflare/mcp-server-form.ts +219 -0
  115. package/src/index.ts +1 -0
  116. package/src/launchd/file-converge.ts +233 -0
  117. package/src/launchd/file-identity.ts +69 -0
  118. package/src/launchd/host-file-alias.test.ts +65 -0
  119. package/src/launchd/host-file-form.ts +2 -1
  120. package/src/launchd/host-file-lifecycle.ts +25 -144
  121. package/src/launchd/host-file.ts +8 -2
  122. package/src/launchd/index.ts +2 -0
  123. package/src/launchd/local-runner.test.ts +12 -1
  124. package/src/launchd/local-runner.ts +2 -0
  125. package/src/launchd/port-claims.test.ts +124 -0
  126. package/src/launchd/port-claims.ts +102 -0
  127. package/src/launchd/providers.test.ts +3 -0
  128. package/src/launchd/runner.ts +17 -2
  129. package/src/launchd/sudo-runner.ts +11 -0
  130. package/src/linux/sudo-listing.ts +122 -0
  131. package/src/proxmox/credential-literals.ts +1 -1
  132. package/src/proxmox/generated/pbs/nodes-node.ts +1 -1
  133. package/src/proxmox/generated/pbs.ts +7 -0
  134. package/src/proxmox/generated/pve.ts +7 -0
  135. package/src/proxmox/index.ts +1 -1
  136. package/src/proxmox/pbs-notification-target-form.ts +1 -1
  137. package/src/proxmox/pbs-notification-target-lifecycle.ts +1 -1
  138. package/src/proxmox/pbs-notification-target-wire.ts +1 -1
  139. package/src/proxmox/pbs-notification-target.ts +1 -1
  140. package/src/release/adopt-parity.test.ts +106 -0
  141. package/src/release/apply-pins.test.ts +128 -0
  142. package/src/release/archive.ts +94 -0
  143. package/src/release/binary-alias.test.ts +109 -0
  144. package/src/release/binary-claim.ts +80 -0
  145. package/src/release/binary-claims.test.ts +47 -0
  146. package/src/release/binary-diff.ts +73 -0
  147. package/src/release/binary-form.ts +229 -0
  148. package/src/release/binary-lifecycle.test.ts +90 -0
  149. package/src/release/binary-lifecycle.ts +202 -0
  150. package/src/release/binary-read.ts +55 -0
  151. package/src/release/binary-refusals.test.ts +192 -0
  152. package/src/release/binary-sudo.test.ts +93 -0
  153. package/src/release/binary.ts +181 -0
  154. package/src/release/catalog.test.ts +167 -0
  155. package/src/release/catalog.ts +238 -0
  156. package/src/release/checksums.test.ts +35 -0
  157. package/src/release/checksums.ts +43 -0
  158. package/src/release/declared-pins.ts +39 -0
  159. package/src/release/download.test.ts +138 -0
  160. package/src/release/download.ts +110 -0
  161. package/src/release/error-channel.test.ts +69 -0
  162. package/src/release/fake-deploy.ts +87 -0
  163. package/src/release/fake-engine.ts +50 -0
  164. package/src/release/fake-install.ts +59 -0
  165. package/src/release/fake-provider.ts +68 -0
  166. package/src/release/fake-release.ts +185 -0
  167. package/src/release/fixtures/openbao/checksums.txt +83 -0
  168. package/src/release/fixtures/openbao/checksums.txt.gpgsig +0 -0
  169. package/src/release/fixtures/openbao/darwin-assets.txt +12 -0
  170. package/src/release/fixtures/openbao/openbao-gpg-pub-20240618.asc +109 -0
  171. package/src/release/fixtures/victoria/darwin-arm64-assets.txt +22 -0
  172. package/src/release/fixtures/victoria/victoria-logs-darwin-arm64-v1.52.0_checksums.txt +2 -0
  173. package/src/release/fixtures/victoria/victoria-metrics-darwin-arm64-v1.151.0_checksums.txt +2 -0
  174. package/src/release/fixtures/victoria/victoria-traces-darwin-arm64-v0.10.0_checksums.txt +2 -0
  175. package/src/release/fixtures/victoria/vmutils-darwin-arm64-v1.151.0_checksums.txt +8 -0
  176. package/src/release/index.ts +44 -0
  177. package/src/release/openbao.test.ts +120 -0
  178. package/src/release/openbao.ts +86 -0
  179. package/src/release/plan.test.ts +189 -0
  180. package/src/release/provider-refusals.test.ts +48 -0
  181. package/src/release/providers.test.ts +175 -0
  182. package/src/release/providers.ts +24 -0
  183. package/src/release/recovery-read.test.ts +44 -0
  184. package/src/release/refused.ts +40 -0
  185. package/src/release/tar.test.ts +142 -0
  186. package/src/release/tar.ts +195 -0
  187. package/src/release/victoria.test.ts +108 -0
  188. package/src/release/victoria.ts +154 -0
  189. package/src/{proxmox → secrets}/write-only.ts +6 -0
  190. /package/dist/{proxmox → secrets}/write-only.d.ts +0 -0
  191. /package/src/{proxmox → secrets}/write-only.test.ts +0 -0
@@ -0,0 +1,151 @@
1
+ # Release binaries — the OpenBao walk-down
2
+
3
+ Status: active
4
+ Verified: 2026-09-23
5
+
6
+ `OPENBAO_RELEASES` (`src/release/openbao.ts`) is the second data set, and the
7
+ first whose checksum file never lists its own binary. Every command below was
8
+ run 2026-09-23 against `openbao/openbao` v2.6.2, from this worktree, network
9
+ reads only — nothing executed the vendor binary.
10
+
11
+ ## The release itself
12
+
13
+ ```
14
+ gh api repos/openbao/openbao/releases/tags/v2.6.2
15
+ ```
16
+
17
+ `published_at` 2026-08-18T16:21:00Z, `immutable: false`, 251 assets.
18
+ `openbao_2.6.2_darwin_arm64.tar.gz`: 76,833,570 bytes,
19
+ `digest: sha256:4e495376174accc0e014d31e9901f518a974f966850c839f626347eaac05fd52`.
20
+
21
+ ## checksums.txt
22
+
23
+ ```
24
+ curl -sL -o checksums.txt \
25
+ https://github.com/openbao/openbao/releases/download/v2.6.2/checksums.txt
26
+ shasum -a 256 checksums.txt
27
+ ```
28
+
29
+ 8,683 bytes, `sha256 3d4a19fdc54a86fd94ce59ea91316dbf4304180bb27ae818e78e85c7f2164645`
30
+ — equal to GitHub's own `digest` for the asset. 83 lines, every one matching
31
+ `^[0-9a-f]{64} [^\s/]+$` (`checksums.ts`'s parser), LF endings, a trailing
32
+ newline, no name repeated. Line 20 is the darwin_arm64 archive and equals the
33
+ release API's digest. **41 of the 83 lines are `.sbom.json`; the rest are
34
+ archives, `.deb` or `.rpm` — never `bao` itself**: `grep -n ' bao$'
35
+ checksums.txt` finds nothing. That is why the member digest is `computed`,
36
+ never `members` (checksums.ts's header explains the shape generally; this is
37
+ OpenBao's instance of it).
38
+
39
+ ## The signature over checksums.txt
40
+
41
+ ```
42
+ curl -sL -o checksums.txt.gpgsig \
43
+ https://github.com/openbao/openbao/releases/download/v2.6.2/checksums.txt.gpgsig
44
+ curl -sL -o openbao-gpg-pub-20240618.asc \
45
+ https://openbao.org/assets/openbao-gpg-pub-20240618.asc
46
+ gpg --dearmor -o openbao-pub.gpg openbao-gpg-pub-20240618.asc
47
+ gpgv --keyring ./openbao-pub.gpg checksums.txt.gpgsig checksums.txt
48
+ ```
49
+
50
+ ```
51
+ gpgv: Signature made Tue Aug 18 11:57:25 2026 EDT
52
+ gpgv: using RSA key E617DCD4065C2AFC0B2CF7A7BA8BC08C0F691F94
53
+ gpgv: Good signature from "OpenBao <openbao@lists.lfedge.org>"
54
+ ```
55
+
56
+ `gpg --show-keys --with-fingerprint` on the same file: primary
57
+ `66D1 5FDD 8728 7219 C8E1 5478 D200 CD70 2853 E6D0`, signing subkey
58
+ `E617 DCD4 065C 2AFC 0B2C F7A7 BA8B C08C 0F69 1F94` — both match OpenBao's own
59
+ `install.mdx` at this tag. `checksums.txt.gpgsig` is 566 bytes, sha256
60
+ `40f294d642a2b4e9adac766ca6b8b2633e15b3dbfdf63969dedbf46d94df53e8`; the key
61
+ file is 6,896 bytes, sha256
62
+ `1862a196422947124282e026ea4e09d6f7c5d383b628d90c17906936e6e5a8e0`.
63
+
64
+ ⚠️ **Trust on first use, across two channels one project runs**: the release
65
+ asset (GitHub) and the key (openbao.org) are both OpenBao's own
66
+ infrastructure. A verified signature says the two agree, not that a third
67
+ party attests to either. `cosign` is not installed on the mini (`which
68
+ cosign` — not found), so the Sigstore bundle alongside `checksums.txt` was
69
+ read, not verified: its certificate names
70
+ `https://github.com/openbao/openbao/.github/workflows/release.yml@refs/heads/release/2.6.x`,
71
+ issuer `https://token.actions.githubusercontent.com` — recorded in
72
+ `openbao.ts` as an **unverified** identity. "GPG or Sigstore" is met by the
73
+ GPG half.
74
+
75
+ Attestations, for completeness: `gh api
76
+ repos/openbao/openbao/attestations/sha256:4e49…` → 404. Positive control
77
+ (`gh api repos/cli/cli/attestations/sha256:<its macOS_arm64 digest>`) → 200,
78
+ 2 attestations, same day. A 404 here means "none published", not "the API is
79
+ down".
80
+
81
+ ## The archive: download, hash, unpack
82
+
83
+ ```
84
+ curl -sL -o openbao_2.6.2_darwin_arm64.tar.gz \
85
+ https://github.com/openbao/openbao/releases/download/v2.6.2/openbao_2.6.2_darwin_arm64.tar.gz
86
+ shasum -a 256 openbao_2.6.2_darwin_arm64.tar.gz
87
+ tar -tvzf openbao_2.6.2_darwin_arm64.tar.gz
88
+ ```
89
+
90
+ 76,833,570 bytes, sha256
91
+ `4e495376174accc0e014d31e9901f518a974f966850c839f626347eaac05fd52` — equal to
92
+ the pin. `tar -tvzf` (full stream, nothing saved beyond the archive itself):
93
+ four root-level regular files, `ustar`, no PAX header, no directory entry —
94
+
95
+ ```
96
+ -rw-r--r-- 0 runner runner 131858 CHANGELOG.md
97
+ -rw-r--r-- 0 runner runner 15958 LICENSE
98
+ -rw-r--r-- 0 runner runner 7136 README.md
99
+ -rwxr-xr-x 0 root root 193769394 bao
100
+ ```
101
+
102
+ The kit's own `extractMembers` (`archive.ts`) read the same stream without
103
+ refusing it and returned the same four names, in the same order — the
104
+ previous partial-range read (`release-binary-catalogs.md`'s queue note) is
105
+ superseded by this full-stream one, so **no tar-reader change is needed for
106
+ this vendor**.
107
+
108
+ ## The member digest — computed, not the vendor's
109
+
110
+ ```
111
+ tar -xzOf openbao_2.6.2_darwin_arm64.tar.gz bao | shasum -a 256
112
+ ```
113
+
114
+ `d476d17e81a35e6d70dd7e86a8ab2a3664313525118f1f1cfe0130e7a2b95f3a` — and the
115
+ kit's `extractMembers` over the same bytes produced the identical digest. Two
116
+ independent readers agree, which is what `computed` in `openbao.ts` records,
117
+ dated, next to a header explaining it is not a vendor fact.
118
+
119
+ ## The extracted binary, never run
120
+
121
+ ```
122
+ tar -xzf openbao_2.6.2_darwin_arm64.tar.gz bao
123
+ chmod -x bao # never made executable in this worktree
124
+ otool -L bao
125
+ codesign -dv --verbose=2 bao
126
+ xattr -l bao
127
+ ```
128
+
129
+ `otool -L`: `libSystem.B.dylib`, `libresolv.9.dylib`, CoreFoundation,
130
+ Security — all system frameworks, none from `/nix/store` — the same class as
131
+ Victoria's five. `codesign`: `flags=0x20002(adhoc,linker-signed)`, no
132
+ TeamIdentifier — a stock Go/macOS ad-hoc signature, same as the Victoria
133
+ binaries. `xattr -l`: `com.apple.provenance` only, no quarantine. Build
134
+ provenance (`goreleaser.other.yaml` at v2.6.2, read, not re-measured here):
135
+ `CGO_ENABLED=0`, tag `ui` — the same build class the darwin release ships for
136
+ every OpenBao platform.
137
+
138
+ Both scratch copies (`bao`, the `.tar.gz`) were deleted after these commands;
139
+ nothing was executed and nothing was written outside the scratch directory.
140
+
141
+ ## What this does NOT establish
142
+
143
+ - **Sigstore identity is read, not cryptographically checked** — `cosign` was
144
+ not run. Treat the workflow identity above as a claim, not a verification.
145
+ - **The agent never ran `bao`.** Whether it starts under launchd, and whether
146
+ its approle auto-auth / template / exec behaviour matches the Nix build
147
+ the vault server runs today, is unmeasured — expected to be unaffected
148
+ (nothing here suggests otherwise), but nobody has watched it happen.
149
+ - **No adoption or install-path measurement.** This PR adds only the catalog
150
+ entry, its tests and this record — no `ReleaseBinary` declaration, no
151
+ consuming stack change.
@@ -0,0 +1,57 @@
1
+ # Release binaries — every refusal
2
+
3
+ Status: active
4
+ Verified: 2026-09-22
5
+
6
+ Everything `Release.Binary` refuses, by where it is refused. The guide is
7
+ [release-binary.md](./release-binary.md). ★ One page per subject: the guide says
8
+ what a declaration does, this says what stops it, and a new refusal lands here.
9
+
10
+ In the stack program (`catalogBinary`): a package, version, platform or
11
+ binary the data set does not pin, including `v1.151.0`, `1.151.0-enterprise`,
12
+ `victoria-metrics-cluster`, `vmalert-prod` and inherited keys like
13
+ `constructor`.
14
+
15
+ At plan (the probe and `diff`), before anything is touched: a pin that is
16
+ missing or not a plain value (an Output pin would be a fetch; a first deploy
17
+ is never diffed, so reconcile refuses it there, before any host call, from the
18
+ props as declared); a repo that is not `owner/name`, or whose name is `.` or
19
+ `..`; a tag, asset or name that is not one safe path segment; an asset that is
20
+ not a `.tar.gz` or `.tgz` (a glob is not a name); a size that is not an
21
+ integer from 1 byte to 1 GiB (it is allocated up front); a digest that is not
22
+ 64 lower-case hex; a member that is absolute or climbs. Also a relative or
23
+ unnormalised directory; a mode that is setuid, setgid, sticky, group- or
24
+ world-writable, or not owner-readable and owner-executable; an owner or group
25
+ that is not a valid name or id; a new pin at the same path.
26
+
27
+ ⚠️ **Not on a first deploy with `directory: dir.path`.** Alchemy neither
28
+ probes nor diffs a create whose props hold an Output, so there every check
29
+ above runs at apply, after `HostDirectory` has made the directory (measured: a
30
+ group-writable mode planned `create`, then the apply ran `mkdir` and refused).
31
+ Nothing is written to the binary's path. A failed first install never bricks
32
+ the next plan: the recovery read of its row answers "nothing recovered"
33
+ (`binary-read.ts`).
34
+
35
+ At plan, only under `--adopt` (the probe then hands `diff` what it read): a
36
+ file at the path that is not the pinned binary, or not a regular file.
37
+
38
+ At apply, before the download: a second resource installing the same path in
39
+ one deploy (two jobs that run one binary share one `ReleaseBinary`; measured,
40
+ two owners meant dropping either deleted the other's file); a missing
41
+ directory, one that is a symlink, or one that group or other may write; a
42
+ symlink or directory at the path; a file the resource does not own (without
43
+ `--adopt`, even the pinned bytes; with it, anything but the pinned bytes); a
44
+ chown without root.
45
+
46
+ From the vendor: a status other than 200; a body past or short of the pinned
47
+ size; an archive whose SHA-256 is not the pin; an archive entry that is
48
+ absolute, has a `..` segment, is a hard link, symlink, directory, device, FIFO
49
+ or contiguous file, is a GNU long-name or PAX header, is named twice, fails its
50
+ header checksum, uses base-256, is neither ustar nor GNU, a lone zero block,
51
+ data after the end, a truncated archive; the declared member missing; the
52
+ member's SHA-256 not its pin. Every one of these ends `Nothing was written.`
53
+
54
+ After the write: a file that does not read back as declared (a create is
55
+ removed). Errors are tagged (`BinaryRefused`, `DownloadFailed`,
56
+ `ChecksumMismatch`, `ArchiveRefused`) and carry the house refusal sentence.
57
+ ⚠️ Tagged, not typed: no caller can `catchTag` one yet (`error-channel.test.ts`).
@@ -0,0 +1,163 @@
1
+ # Release binaries — what upstream would need
2
+
3
+ Status: active
4
+ Verified: 2026-09-22 (against `alchemy-run/alchemy` at `v2.0.0-beta.79`, read-only:
5
+ AGENTS.md and `scripts/generate-api-reference.ts`, plus the installed
6
+ `src/Command/Exec.ts`, `src/GitHub/Release.ts`, `src/GitHub/BaseUrl.ts`,
7
+ `src/Util/AtomicFile.ts`, `src/Hetzner/Ssh.ts` and `src/Hetzner/Providers.ts`)
8
+
9
+ `Release.Binary` is built so it **could** be contributed to Alchemy. This page
10
+ is the honest gap list: what already fits, and what a pull request to
11
+ `alchemy-run/alchemy` would have to change first. Nothing here is a promise
12
+ that upstream wants it.
13
+
14
+ ## Already shaped for it
15
+
16
+ - **Generic before its first release.** It began as `Victoria.Binary` with the
17
+ catalog inside the resource. It was generalised before any state row carried
18
+ that type name, because a rename after release would be a state migration.
19
+ The pinned archive (repo, tag, exact asset name, size, archive digest,
20
+ member, member digest, installed name) is props. Vendor data sets sit beside
21
+ it as plain modules. Alchemy could ship the resource without shipping anyone's
22
+ pins.
23
+ - **Named the upstream way.** The type is `Release.Binary` (`Namespace.Kind`).
24
+ Under upstream's layout it would be `src/Release/Binary.ts`, imported as
25
+ `import * as Release from "alchemy/Release"`, used as `Release.Binary`. The
26
+ house exports the prefixed `ReleaseBinary`, as it does `HostFile` and
27
+ `LaunchdJob`. Upstream's `GitHub.Release` already means _publishing_ a
28
+ release, so the GitHub namespace would be the confusing home.
29
+ - **The lifecycle is beta.79's**: `read`, `diff`, `reconcile`, `delete`,
30
+ `list` returning `[]`. No `create`/`update`. `reconcile` is one flow —
31
+ observe the path, ensure the bytes, sync mode and owner, read back — not a
32
+ create body and an update body. ⚠️ It does branch on `output` and `olds` for
33
+ **ownership**: only a create may take over a file under `--adopt`
34
+ (`file-converge.ts`), and the in-place guard compares `olds`' pins (gap 3).
35
+ - **Props are plain types**, never `Input<T>`, and every prop has JSDoc, with
36
+ `@default` where one applies. ⚠️ Not every attribute: `path`, `mode`, `uid`,
37
+ `gid` and `size` come from `HostFileAttributes` with no JSDoc, so the
38
+ generator would print them bare.
39
+ - **The resource JSDoc is the generator's shape**: `### Installing a binary`,
40
+ `**Example:**` with fenced TypeScript, and `@resource` last. (An untitled
41
+ example is titled "Example" by the generator.) ⚠️ The example needs
42
+ `HostDirectory`, `LaunchdJob` and `VICTORIA_RELEASES`, which upstream lacks.
43
+ - **Errors are tagged** (`Data.TaggedError`: `BinaryRefused`, `DownloadFailed`,
44
+ `ChecksumMismatch`, `ArchiveRefused`), and nothing casts on `_tag`.
45
+ ⚠️ **They are not typed end to end, so no caller can `catchTag` one**
46
+ (measured, `error-channel.test.ts`). `catalogBinary()` throws, which in a
47
+ stack program is a defect that `catchTag` never sees. The provider's `lift()`
48
+ keeps the instance at runtime but types the channel as `Error` (gap 10).
49
+ - **HTTP is Effect's `HttpClient`**, and `reconcile` reports progress through
50
+ `session.note`. A provider layer that brings `FetchHttpClient.layer` itself
51
+ has upstream precedent (`Hetzner/Providers.ts`).
52
+ - **The props carry nothing estate-specific.** Paths, owners and modes are the
53
+ stack's. ⚠️ The wiring does carry the estate's name: the host service is
54
+ keyed `homeflare/launchd/HostRunner`, and the ownership notes are
55
+ `Artifacts` keys `homeflare/ownership/*` (gaps 1 and 2).
56
+
57
+ ## What would have to change
58
+
59
+ 1. **The host seam.** Every host touch goes through the kit's promise-based
60
+ `HostRunner`, a Context service under `launchd/`. Upstream would expect an
61
+ Effect service with a neutral key. ⚠️ `Util/AtomicFile.ts` is not that
62
+ service as it stands: it writes a **string** (`writeFileString`) and a mode,
63
+ never bytes, a uid or a gid. `FileSystem` reaches only the deploying
64
+ machine. Upstream's one remote-host seam is `Hetzner.Ssh` (Effect `exec` and
65
+ `scp`). A contribution needs a host service with a local implementation over
66
+ `FileSystem` and a remote one in that shape. The house seam is one file wide
67
+ on purpose, so the swap is local.
68
+ 2. **Ownership semantics.** The house refuses to adopt without `--adopt`:
69
+ `read` returns `Unowned` even for a byte-identical file, and `reconcile`
70
+ resolves adoption at apply through `ownership/adopt.ts`. That reads
71
+ `Stack.resources[fqn].Adopt`, which upstream marks `@internal`, and state
72
+ rows. Upstream's default for a resource without ownership metadata is
73
+ **plain attributes, which means silent adoption**. A contribution would drop
74
+ `adoptsAtApply` / `noteUnfinished`, ask the engine to hand `adopt` to
75
+ `reconcile`, and settle whether a file on disk is ever adopted silently.
76
+ Worth keeping in any form: a file whose digest is not the pin is never
77
+ adopted, because adopting it is overwriting it (`binary-claim.ts`).
78
+ ⚠️ `declared-pins.ts` reads the same `@internal` record (`.Props`) to refuse
79
+ a pin that was an Output on a first deploy. Upstream applies `Input<T>` to
80
+ every prop automatically, so "this prop must be a literal" is an engine
81
+ feature to propose (the declared props handed to `reconcile`, or a
82
+ literal-only marker), not something a provider can reach for.
83
+ 3. **`diff` does live I/O and returns `noop`.** Upstream's guidance is that a
84
+ diff should almost never return `noop`. Live I/O has precedent, though:
85
+ `Command.Exec`'s diff hashes a directory and answers `noop` on a match, the
86
+ same shape as this one. Two rules are worth keeping in any form: a new pin
87
+ or path → `replace`, even while `directory` is an Output; and a new pin at
88
+ the same path is refused. The apply-time half of that refusal reads `olds`,
89
+ which upstream's reconciler doctrine calls "at most a hint". Restated as
90
+ observation it reads: the recorded path's live file is not these bytes.
91
+ 4. **Layout and exports.** Upstream co-locates contract and provider in
92
+ `src/{Cloud}/{Service}/{Resource}.ts`, re-exports with `export *`, and
93
+ registers providers in a `Providers.ts` collection that `alchemy unsafe nuke`
94
+ enumerates. The house splits files under a 250-line cap, curates its barrel,
95
+ and exports `releaseProviders(runner)` with no collection.
96
+ 5. **Where the data sets live.** Upstream ships no estate's pins. The resource,
97
+ `catalogBinary()` and the tar reader would go upstream; `victoria.ts` and its
98
+ fixtures would stay in the kit, or become a documented example.
99
+ 6. **Tests.** The house tests run on fakes (`bun:test`, no network, no host),
100
+ plus Alchemy's real Plan and Apply over in-memory state (`plan.test.ts`).
101
+ Upstream tests run `pnpm test` against the real target, start and end with
102
+ `stack.destroy()`, and verify out of band. For a host resource the honest
103
+ equivalent is a real temporary directory and a real download. That was done
104
+ once, by hand, before the generalisation (release-binary-measured.md), and
105
+ CI here deliberately never does it.
106
+ 7. **Toolchain and style.** pnpm, the alchemy-test runner, `tsc -b`, double
107
+ quotes; generated docs via `pnpm docs:gen`, never hand-edited.
108
+ 8. **The pull request itself.** A conventional-commit title; no `#` or `##`
109
+ headings in the body (the smallest is `###`), the summary first with no
110
+ heading, no test plan, and code over prose.
111
+ 9. **The directory must already exist.** `reconcile` refuses a missing one, and
112
+ the kit's `HostDirectory` (linux subpath) is what creates it. Upstream has no
113
+ such resource, so a contribution brings one, or a prop that creates the
114
+ directory with a declared mode and owner.
115
+ 10. **A typed error channel.** `catalogBinary()` would return an `Effect` that
116
+ fails with `BinaryRefused`, and the provider would fail with the tag union
117
+ instead of `lift()`'s `Error`. `error-channel.test.ts` fails the moment
118
+ either changes, so this page moves with it.
119
+ 11. **The tar reader accepts one measured layout.** Measured 2026-09-22 against
120
+ `extractMembers`: macOS's default `tar czf` (bsdtar 3.5.3) writes a PAX `x`
121
+ header before every entry (a sub-second `mtime` and the
122
+ `com.apple.provenance` xattr), so even a one-file archive is refused whole.
123
+ `--no-mac-metadata` still writes them; `--format ustar` passes. A wrapping
124
+ directory entry is refused whole. Go's `archive/tar` writing root-level
125
+ files passes. The house accepts this on purpose: the Victoria archives are
126
+ flat, and homeflare-builds writes plain ustar. ⚠️ REASONED NOT MEASURED:
127
+ the Prometheus family's archives (`alertmanager-0.34.1.darwin-arm64.tar.gz`)
128
+ wrap their files in a directory, so the queue in
129
+ release-binary-catalogs.md needs a reader change, not only a data set.
130
+ Generic replacement: skip directory entries, and links that are not the
131
+ declared member, because nothing is ever written from an entry name; read
132
+ PAX records and refuse only a `path`, `linkpath` or `size` record. The
133
+ member digest still guards the bytes.
134
+ 12. **One source, one tag alphabet, other prop names.** `releaseUrl` fixes
135
+ `https://github.com`. Upstream's GitHub family also reaches GitHub
136
+ Enterprise (`normalizeGitHubBaseUrl`, `GitHub/BaseUrl.ts`). A tag must be
137
+ one `[A-Za-z0-9._-]` segment, so kustomize's `kustomize/v5.8.1` (measured:
138
+ GitHub's own download URL carries the raw `/`) and a changesets-style
139
+ `pkg@1.2.3` are refused at plan. Generic: validate git's ref-name rules,
140
+ segment by segment. Upstream's GitHub props split `owner` and `repository`
141
+ and call a tag `tagName`; this takes `repo: "owner/name"` and `tag`.
142
+ 13. **No platform check.** `platform` is a catalog key, not a prop, and nothing
143
+ compares the archive's platform with the host. On a Linux runner, a stack
144
+ that asks for `darwin-arm64` gets a Mach-O written, read back and reported
145
+ installed; its unit then fails with "exec format error". Generic: a
146
+ `platform` prop, checked against the host (`uname -sm` through the host
147
+ service) or against the member's executable header.
148
+
149
+ ## What should NOT change on the way
150
+
151
+ - ⛔ **Pins in code, never a checksum file fetched at apply.** Mutable releases
152
+ make a fetched checksum file a claim by the same party whose bytes it vouches
153
+ for. A pin that is an Output is refused for the same reason.
154
+ - ⛔ **Two layers.** Verify the archive before unpacking and the member before
155
+ writing. The member check is what still holds if a reader is ever wrong about
156
+ a boundary.
157
+ - ⛔ **Exact names.** An asset and a member are matched whole, never by a
158
+ prefix, a glob or "contains darwin-arm64", because the enterprise and cluster
159
+ siblings share every prefix.
160
+ - ⛔ **Refuse the whole archive** for any unsafe entry, extract only what was
161
+ declared, and stage nothing on disk before the one atomic write. ⚠️ Gap 11 is
162
+ where upstream is likeliest to push back: which entries count as unsafe when
163
+ nothing is written from an entry name.
@@ -0,0 +1,165 @@
1
+ # Release binaries — `@homeflare/alchemy/release`
2
+
3
+ Status: active
4
+ Verified: 2026-09-23
5
+
6
+ `Release.Binary` (`ReleaseBinary`) installs one binary out of a pinned release
7
+ archive, verified twice: the archive against its pinned SHA-256 before anything
8
+ is unpacked, then the binary against its own before it is written. The pinned
9
+ archive is **props**. Each vendor's pinned versions are **data** kept beside
10
+ the resource, and `VICTORIA_RELEASES` is the first data set. It exists to
11
+ replace the Nix-built Victoria binaries on the Mac host: all five Nix builds
12
+ link a `/nix/store` `libresolv`, and the vendor builds link none.
13
+
14
+ ⛔ **It installs; it never starts.** No `launchctl`, no `systemctl`, no restart.
15
+ The daemon is the stack's own `LaunchdJob` (or systemd unit), and it puts the
16
+ binary's `path` in its argv.
17
+
18
+ ## Declaring one
19
+
20
+ ```ts
21
+ import { HostDirectory, linuxProviders } from '@homeflare/alchemy/linux';
22
+ import { LaunchdJob, launchdProviders, localRunner } from '@homeflare/alchemy/launchd';
23
+ import {
24
+ ReleaseBinary, VICTORIA_RELEASES, catalogBinary, catalogDirectory, releaseProviders,
25
+ } from '@homeflare/alchemy/release';
26
+
27
+ const runner = localRunner();
28
+ // providers: Layer.mergeAll(launchdProviders(runner), linuxProviders(runner), releaseProviders(runner))
29
+
30
+ const vmalertOf = { package: 'vmutils', version: '1.151.0', platform: 'darwin-arm64', binary: 'vmalert' };
31
+ const dir = yield* HostDirectory('vmutils-1.151.0', {
32
+ path: catalogDirectory('/opt/example/bin', vmalertOf), // …/vmutils-1.151.0
33
+ mode: 0o755, owner: 0, group: 0,
34
+ });
35
+ const vmalert = yield* ReleaseBinary('vmalert', {
36
+ ...catalogBinary(VICTORIA_RELEASES, vmalertOf), // archive, member, sha256, name
37
+ directory: dir.path, // ★ an Output: the engine creates the directory first
38
+ owner: 0, group: 0, mode: 0o555,
39
+ });
40
+ yield* LaunchdJob('vmalert-job', { programArguments: [vmalert.path, '--httpListenAddr=127.0.0.1:8880'], … });
41
+ ```
42
+
43
+ ⚠️ **Give every resource its own logical id, even across different types.**
44
+ alchemy@2.0.0-beta.79's FQN is `namespace + id` alone (`FQN.ts`) — it does
45
+ NOT fold in the resource type. `stack.resources[fqn]` (`Resource.ts`)
46
+ returns the **first** registration for a repeated id whatever type it was:
47
+ `ReleaseBinary('vmalert', …)` then `LaunchdJob('vmalert', …)` in the same
48
+ namespace silently hands the job call back the _binary_ resource, and the
49
+ job is never registered at all — no error, no second row in the plan, just a
50
+ job that does not exist. Measured against the installed alchemy package,
51
+ 2026-09-23. Naming them `'vmalert'` and `'vmalert-job'` (as above) avoids it;
52
+ so does any other pair of distinct ids.
53
+
54
+ - **`catalogBinary()` runs in your stack program.** A version, package,
55
+ platform or binary the data set does not pin throws `BinaryRefused` there,
56
+ so `alchemy plan` fails before any resource is asked anything
57
+ (`plan.test.ts`: zero requests, zero host calls).
58
+ - **The directory is yours, one per version.** It must exist.
59
+ `catalogDirectory(root, …)` names it `<root>/<package>-<version>`. A version
60
+ bump is then a new path. Measured through the engine (`plan.test.ts`):
61
+ mkdir new, download, write new, remove old, rmdir old, in that order.
62
+ - ⛔ **A new pin at the same path is refused**, at plan when the path is
63
+ resolved and at apply otherwise, rather than overwriting a binary under the
64
+ daemon that runs it. 🔴 While the directory is still an Output, a re-pin of
65
+ the same bytes is planned as an `update`, never a `replace`: measured through
66
+ the engine, a `replace` onto an unmoved path took the identical file as the
67
+ new generation's, then deleted it with the old one, and reported success.
68
+ - **The job references `vmalert.path`**, not a string it built. That Output
69
+ orders the job after the binary. A version bump moves the path, so the plist
70
+ changes and the job restarts on the new binary before the old one is deleted.
71
+ - **Declare each path once.** vmalert-logs is vmalert with other flags: one
72
+ `ReleaseBinary`, referenced by both jobs.
73
+ - **One logical id per binary, one per versioned directory.** `plan.test.ts`
74
+ declares `ReleaseBinary('vmalert')` and `HostDirectory('vmutils-<version>')`:
75
+ a bump plans the binary as a `replace`, the new directory as a `create` and
76
+ the old one as a `delete`, and the engine removes the old binary before its
77
+ directory.
78
+ - **Nothing estate-specific is in the kit.** Root, owner, group and mode are
79
+ your props. The data sets hold vendor facts only.
80
+ - **One runner for all of it.** Pass the same `HostRunner` to
81
+ `releaseProviders()` as to the jobs. There is no default: a binary goes
82
+ wherever the runner reaches. It provides its own `FetchHttpClient.layer`.
83
+ - **Your own pins work too.** The props are plain data, so a stack may write
84
+ them itself. ⛔ They must be plain values in the stack program: a pin wired
85
+ from another resource's Output (a checksum file read at apply) is refused,
86
+ on a first deploy too (`apply-pins.test.ts`). ⚠️ A stack program that
87
+ fetches the file itself and passes plain strings cannot be told apart from
88
+ a reviewed pin; `catalogBinary()` over a reviewed data set is the path
89
+ that keeps them reviewed.
90
+
91
+ ## What reconcile does, in order
92
+
93
+ 1. **Validate every prop**, before any host or network call. Every pin must be a
94
+ plain, well-formed value, as the stack program DECLARED it: by now an Output
95
+ has resolved to a plausible string (`declared-pins.ts`).
96
+ 2. **Check the directory exists**, is not a symlink, and is not writable by
97
+ group or other (they could rename a file over the binary), before any download.
98
+ 3. **Observe the path.** A symlink or directory there is refused. A file this
99
+ resource does not own is refused without `--adopt`, even one holding the
100
+ pinned bytes: at plan the probe calls it `Unowned`, and at apply, when a
101
+ prop was an Output and the probe never ran, reconcile refuses it the same
102
+ way (`binary-claim.ts`). ⛔ A file with other bytes is refused under
103
+ `--adopt` too, at plan and at apply: adopting it would overwrite it in
104
+ place, and the plan would print that as `adopted`. Under `--adopt`, the
105
+ pinned bytes are kept: no download, and a write only to fix mode or owner.
106
+ ⚠️ A rename is not an adoption: declare it with `renamedFrom()`. Under
107
+ `--adopt` the new name takes the file, and the old name's delete leaves a
108
+ path another declaration installed in the same deploy (`adopt-parity.test.ts`).
109
+ 4. **Download** `https://github.com/<repo>/releases/download/<tag>/<asset>`,
110
+ into memory, never past the pinned size. Two binaries from one archive at
111
+ once share one download.
112
+ 5. **Verify the archive** against its pinned SHA-256, before unpacking a byte.
113
+ 6. **Unpack only the declared member**, streaming. The tar reader refuses the
114
+ whole archive for any unsafe entry, declared or not.
115
+ 7. **Verify the member** against its own pinned SHA-256.
116
+ 8. **Write** through `HostRunner.writeFileAtomic`, the one writer every file
117
+ resource uses: a temp file in the same directory, mode and owner set, then
118
+ rename. Under `sudoRunner`, one staged `install -S`, ⚠️ which sets the mode
119
+ just after its rename (measured: `0600` for under half a millisecond).
120
+ 9. **Read back** and compare. A create that does not read back, or whose
121
+ read-back fails, is removed.
122
+
123
+ A binary with the right bytes but the wrong mode or owner is re-written from
124
+ the bytes already on disk, re-hashed, without a download.
125
+
126
+ ⛔ A directory respelled to the same place (a symlinked parent, or case on
127
+ APFS) is an `update` that keeps the file, never a `replace`: the replace's
128
+ Phase 2 would delete the old path, which is the file (`launchd/file-identity.ts`).
129
+
130
+ ## Trust: why the digests are pinned in code
131
+
132
+ ★ **Pinned, not fetched at apply.** A checksum file comes from the same
133
+ account, release and channel as the archive, and every release checked so far
134
+ is mutable (`"immutable": false`). Whoever can swap an archive can swap its
135
+ checksum file, and a provider that trusts what it fetches would call the swap
136
+ verified. A pin moves only through a reviewed change, so a swapped asset is a
137
+ refusal. And a plan knows exactly which bytes it will install without the
138
+ network. Each data-set entry records the checksum file's URL, its own SHA-256
139
+ and the date the pins were copied.
140
+
141
+ ⚠️ **The trust root.** At apply it is the pins alone: TLS, GitHub's redirect,
142
+ its CDN or a proxy can make a download fail, never make other bytes pass. The
143
+ pins rest on one read of the GitHub release over TLS on the `recorded` date
144
+ (trust on first use), checked against GitHub's own asset `digest`, then on
145
+ review of that commit and the kit release the stack's lockfile resolves.
146
+ VictoriaMetrics publishes no signature, cosign bundle, SLSA provenance or
147
+ GitHub attestation for these archives: a checksum proves integrity, not
148
+ authorship.
149
+
150
+ ★ **What CI proves without a network** (`victoria.test.ts`) is consistency,
151
+ not provenance: each committed checksum file in `src/release/fixtures/victoria/`
152
+ hashes to the digest the data set records for it; the strict parser reads
153
+ them; the data set equals what it reads; every asset is in the committed
154
+ listing, and none is an `-enterprise`, `-cluster` or `vlutils` sibling.
155
+ ⚠️ That those recorded digests are GitHub's was measured once, not by CI. A
156
+ change that edits a fixture and its pins together passes; review is the gate.
157
+
158
+ ## Refusals
159
+
160
+ Every refusal, by where it is refused (the stack program, the plan, the apply,
161
+ the vendor's bytes, the read-back): [release-binary-refusals.md](./release-binary-refusals.md).
162
+
163
+ Data sets and adding a vendor: [release-binary-catalogs.md](./release-binary-catalogs.md).
164
+ Measurements and limits: [release-binary-measured.md](./release-binary-measured.md).
165
+ What alchemy-run/alchemy would need: [release-binary-upstream.md](./release-binary-upstream.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homeflare/alchemy",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
4
4
  "description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos, launchd and Caddy, and a verifier that proves adoptions are no-ops.",
5
5
  "homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/alchemy#readme",
6
6
  "bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
@@ -64,6 +64,10 @@
64
64
  "types": "./dist/linux/index.d.ts",
65
65
  "default": "./dist/linux/index.js"
66
66
  },
67
+ "./release": {
68
+ "types": "./dist/release/index.d.ts",
69
+ "default": "./dist/release/index.js"
70
+ },
67
71
  "./caddy": {
68
72
  "types": "./dist/caddy/index.d.ts",
69
73
  "default": "./dist/caddy/index.js"
@@ -0,0 +1,63 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { accessForwardAuth, verifierProblems } from './access-forward-auth.ts';
3
+
4
+ describe('accessForwardAuth', () => {
5
+ test('emits a forward_auth block with the identity headers copied', () => {
6
+ const block = accessForwardAuth({
7
+ verifier: '127.0.0.1:9101',
8
+ body: ['reverse_proxy 127.0.0.1:6080'],
9
+ });
10
+
11
+ expect(block).toBe(
12
+ [
13
+ '\treverse_proxy 127.0.0.1:6080',
14
+ '\tforward_auth 127.0.0.1:9101 {',
15
+ '\t\turi /access/verify',
16
+ '\t\tcopy_headers {',
17
+ '\t\t\tX-Access-Email',
18
+ '\t\t\tX-Access-Sub',
19
+ '\t\t}',
20
+ '\t}',
21
+ ].join('\n'),
22
+ );
23
+ });
24
+
25
+ test('copies exactly the headers the verifier sets, by default', () => {
26
+ // ⛔ A header the verifier emits but Caddy does not copy never reaches the upstream;
27
+ // a header Caddy copies but the verifier never sets is the SPOOFING case — the
28
+ // client's own value survives. These two lists must agree, which is why the default
29
+ // is not configurable-by-accident.
30
+ const block = accessForwardAuth({ verifier: '127.0.0.1:9101' });
31
+ expect(block).toContain('X-Access-Email');
32
+ expect(block).toContain('X-Access-Sub');
33
+ });
34
+
35
+ test('refuses a verifier on a routable address', () => {
36
+ // 🔴 A verifier reachable from off-box can be answered around, and the upstream with it.
37
+ expect(() => accessForwardAuth({ verifier: '10.20.10.250:9101' })).toThrow(/loopback/);
38
+ expect(() => accessForwardAuth({ verifier: '0.0.0.0:9101' })).toThrow(/loopback/);
39
+ });
40
+
41
+ test('accepts loopback in its usual spellings, and a unix socket', () => {
42
+ for (const verifier of ['127.0.0.1:9101', 'localhost:9101', '[::1]:9101', 'unix//run/v.sock']) {
43
+ expect(verifierProblems(verifier)).toEqual([]);
44
+ }
45
+ });
46
+
47
+ test('a custom uri and header names flow through', () => {
48
+ const block = accessForwardAuth({
49
+ verifier: 'unix//run/access.sock',
50
+ uri: '/verify/vnc',
51
+ copyHeaders: ['Remote-Email'],
52
+ });
53
+
54
+ expect(block).toContain('forward_auth unix//run/access.sock {');
55
+ expect(block).toContain('uri /verify/vnc');
56
+ expect(block).toContain('Remote-Email');
57
+ expect(block).not.toContain('X-Access-Sub');
58
+ });
59
+
60
+ test('refuses an empty verifier rather than emitting a block that adapts to nothing', () => {
61
+ expect(() => accessForwardAuth({ verifier: ' ' })).toThrow(/empty/);
62
+ });
63
+ });