@homeflare/alchemy 0.19.1 → 0.21.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 (198) hide show
  1. package/README.md +25 -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.js +173 -120
  24. package/dist/launchd/index.js.map +12 -10
  25. package/dist/launchd/local-runner.d.ts.map +1 -1
  26. package/dist/launchd/runner.d.ts +17 -2
  27. package/dist/launchd/runner.d.ts.map +1 -1
  28. package/dist/launchd/sudo-runner.d.ts +11 -0
  29. package/dist/launchd/sudo-runner.d.ts.map +1 -1
  30. package/dist/linux/index.js +37 -11
  31. package/dist/linux/index.js.map +10 -10
  32. package/dist/linux/region.d.ts +11 -0
  33. package/dist/linux/region.d.ts.map +1 -1
  34. package/dist/linux/remote-file-lifecycle.d.ts.map +1 -1
  35. package/dist/linux/remote-file-plan.d.ts +22 -0
  36. package/dist/linux/remote-file-plan.d.ts.map +1 -1
  37. package/dist/linux/remote-file.d.ts +4 -1
  38. package/dist/linux/remote-file.d.ts.map +1 -1
  39. package/dist/linux/sudo-listing.d.ts +47 -0
  40. package/dist/linux/sudo-listing.d.ts.map +1 -0
  41. package/dist/linux/unit-handlers.d.ts.map +1 -1
  42. package/dist/linux/unit-preflight.d.ts +10 -1
  43. package/dist/linux/unit-preflight.d.ts.map +1 -1
  44. package/dist/proxmox/credential-literals.d.ts +1 -1
  45. package/dist/proxmox/credential-literals.d.ts.map +1 -1
  46. package/dist/proxmox/generated/pbs/nodes-node.d.ts +1 -1
  47. package/dist/proxmox/generated/pbs/nodes-node.d.ts.map +1 -1
  48. package/dist/proxmox/generated/pbs.d.ts +7 -0
  49. package/dist/proxmox/generated/pbs.d.ts.map +1 -1
  50. package/dist/proxmox/generated/pve.d.ts +7 -0
  51. package/dist/proxmox/generated/pve.d.ts.map +1 -1
  52. package/dist/proxmox/index.d.ts +16 -4
  53. package/dist/proxmox/index.d.ts.map +1 -1
  54. package/dist/proxmox/index.js +5 -2
  55. package/dist/proxmox/index.js.map +9 -9
  56. package/dist/proxmox/pbs-notification-target-form.d.ts +1 -1
  57. package/dist/proxmox/pbs-notification-target-form.d.ts.map +1 -1
  58. package/dist/proxmox/pbs-notification-target.d.ts +1 -1
  59. package/dist/proxmox/pbs-notification-target.d.ts.map +1 -1
  60. package/dist/release/archive.d.ts +11 -0
  61. package/dist/release/archive.d.ts.map +1 -0
  62. package/dist/release/binary-claim.d.ts +53 -0
  63. package/dist/release/binary-claim.d.ts.map +1 -0
  64. package/dist/release/binary-diff.d.ts +38 -0
  65. package/dist/release/binary-diff.d.ts.map +1 -0
  66. package/dist/release/binary-form.d.ts +118 -0
  67. package/dist/release/binary-form.d.ts.map +1 -0
  68. package/dist/release/binary-lifecycle.d.ts +55 -0
  69. package/dist/release/binary-lifecycle.d.ts.map +1 -0
  70. package/dist/release/binary-read.d.ts +11 -0
  71. package/dist/release/binary-read.d.ts.map +1 -0
  72. package/dist/release/binary.d.ts +68 -0
  73. package/dist/release/binary.d.ts.map +1 -0
  74. package/dist/release/catalog.d.ts +76 -0
  75. package/dist/release/catalog.d.ts.map +1 -0
  76. package/dist/release/checksums.d.ts +22 -0
  77. package/dist/release/checksums.d.ts.map +1 -0
  78. package/dist/release/declared-pins.d.ts +4 -0
  79. package/dist/release/declared-pins.d.ts.map +1 -0
  80. package/dist/release/download.d.ts +44 -0
  81. package/dist/release/download.d.ts.map +1 -0
  82. package/dist/release/fake-deploy.d.ts +48 -0
  83. package/dist/release/fake-deploy.d.ts.map +1 -0
  84. package/dist/release/fake-engine.d.ts +8 -0
  85. package/dist/release/fake-engine.d.ts.map +1 -0
  86. package/dist/release/fake-install.d.ts +39 -0
  87. package/dist/release/fake-install.d.ts.map +1 -0
  88. package/dist/release/fake-provider.d.ts +58 -0
  89. package/dist/release/fake-provider.d.ts.map +1 -0
  90. package/dist/release/fake-release.d.ts +95 -0
  91. package/dist/release/fake-release.d.ts.map +1 -0
  92. package/dist/release/index.d.ts +24 -0
  93. package/dist/release/index.d.ts.map +1 -0
  94. package/dist/release/index.js +1365 -0
  95. package/dist/release/index.js.map +35 -0
  96. package/dist/release/providers.d.ts +19 -0
  97. package/dist/release/providers.d.ts.map +1 -0
  98. package/dist/release/refused.d.ts +39 -0
  99. package/dist/release/refused.d.ts.map +1 -0
  100. package/dist/release/tar.d.ts +14 -0
  101. package/dist/release/tar.d.ts.map +1 -0
  102. package/dist/release/victoria.d.ts +36 -0
  103. package/dist/release/victoria.d.ts.map +1 -0
  104. package/dist/{proxmox → secrets}/write-only.d.ts.map +1 -1
  105. package/docs/adopted-deploys.md +13 -0
  106. package/docs/linux-host.md +5 -1
  107. package/docs/provider-standard.md +138 -0
  108. package/docs/release-binary-catalogs.md +113 -0
  109. package/docs/release-binary-measured.md +163 -0
  110. package/docs/release-binary-refusals.md +57 -0
  111. package/docs/release-binary-upstream.md +163 -0
  112. package/docs/release-binary.md +154 -0
  113. package/docs/upstream-conformance.md +164 -0
  114. package/package.json +5 -1
  115. package/src/caddy/access-forward-auth.test.ts +63 -0
  116. package/src/caddy/access-forward-auth.ts +120 -0
  117. package/src/caddy/index.ts +10 -0
  118. package/src/caddy/providers.test.ts +4 -0
  119. package/src/cloudflare/mcp-server-api.ts +294 -0
  120. package/src/cloudflare/mcp-server-form.ts +219 -0
  121. package/src/index.ts +1 -0
  122. package/src/launchd/file-converge.ts +233 -0
  123. package/src/launchd/file-identity.ts +69 -0
  124. package/src/launchd/host-file-alias.test.ts +65 -0
  125. package/src/launchd/host-file-form.ts +2 -1
  126. package/src/launchd/host-file-lifecycle.ts +25 -144
  127. package/src/launchd/host-file.ts +8 -2
  128. package/src/launchd/local-runner.test.ts +12 -1
  129. package/src/launchd/local-runner.ts +2 -0
  130. package/src/launchd/runner.ts +17 -2
  131. package/src/launchd/sudo-runner.ts +11 -0
  132. package/src/linux/region.ts +15 -0
  133. package/src/linux/remote-file-lifecycle.test.ts +0 -88
  134. package/src/linux/remote-file-lifecycle.ts +25 -7
  135. package/src/linux/remote-file-plan.ts +37 -1
  136. package/src/linux/remote-file-region.test.ts +181 -0
  137. package/src/linux/remote-file.ts +4 -1
  138. package/src/linux/sudo-listing.ts +122 -0
  139. package/src/linux/unit-handlers.ts +12 -5
  140. package/src/linux/unit-preflight.ts +20 -4
  141. package/src/linux/unit-rename.test.ts +76 -0
  142. package/src/proxmox/ceph-adopt.test.ts +210 -0
  143. package/src/proxmox/credential-literals.ts +1 -1
  144. package/src/proxmox/generated/pbs/nodes-node.ts +1 -1
  145. package/src/proxmox/generated/pbs.ts +7 -0
  146. package/src/proxmox/generated/pve.ts +7 -0
  147. package/src/proxmox/index.test.ts +7 -4
  148. package/src/proxmox/index.ts +16 -4
  149. package/src/proxmox/pbs-notification-target-form.ts +1 -1
  150. package/src/proxmox/pbs-notification-target-lifecycle.ts +1 -1
  151. package/src/proxmox/pbs-notification-target-wire.ts +1 -1
  152. package/src/proxmox/pbs-notification-target.ts +1 -1
  153. package/src/release/adopt-parity.test.ts +106 -0
  154. package/src/release/apply-pins.test.ts +128 -0
  155. package/src/release/archive.ts +94 -0
  156. package/src/release/binary-alias.test.ts +109 -0
  157. package/src/release/binary-claim.ts +80 -0
  158. package/src/release/binary-claims.test.ts +47 -0
  159. package/src/release/binary-diff.ts +73 -0
  160. package/src/release/binary-form.ts +229 -0
  161. package/src/release/binary-lifecycle.test.ts +90 -0
  162. package/src/release/binary-lifecycle.ts +202 -0
  163. package/src/release/binary-read.ts +55 -0
  164. package/src/release/binary-refusals.test.ts +192 -0
  165. package/src/release/binary-sudo.test.ts +93 -0
  166. package/src/release/binary.ts +181 -0
  167. package/src/release/catalog.test.ts +96 -0
  168. package/src/release/catalog.ts +175 -0
  169. package/src/release/checksums.test.ts +35 -0
  170. package/src/release/checksums.ts +43 -0
  171. package/src/release/declared-pins.ts +39 -0
  172. package/src/release/download.test.ts +138 -0
  173. package/src/release/download.ts +110 -0
  174. package/src/release/error-channel.test.ts +69 -0
  175. package/src/release/fake-deploy.ts +87 -0
  176. package/src/release/fake-engine.ts +50 -0
  177. package/src/release/fake-install.ts +59 -0
  178. package/src/release/fake-provider.ts +68 -0
  179. package/src/release/fake-release.ts +185 -0
  180. package/src/release/fixtures/victoria/darwin-arm64-assets.txt +22 -0
  181. package/src/release/fixtures/victoria/victoria-logs-darwin-arm64-v1.52.0_checksums.txt +2 -0
  182. package/src/release/fixtures/victoria/victoria-metrics-darwin-arm64-v1.151.0_checksums.txt +2 -0
  183. package/src/release/fixtures/victoria/victoria-traces-darwin-arm64-v0.10.0_checksums.txt +2 -0
  184. package/src/release/fixtures/victoria/vmutils-darwin-arm64-v1.151.0_checksums.txt +8 -0
  185. package/src/release/index.ts +41 -0
  186. package/src/release/plan.test.ts +189 -0
  187. package/src/release/provider-refusals.test.ts +48 -0
  188. package/src/release/providers.test.ts +174 -0
  189. package/src/release/providers.ts +24 -0
  190. package/src/release/recovery-read.test.ts +44 -0
  191. package/src/release/refused.ts +40 -0
  192. package/src/release/tar.test.ts +142 -0
  193. package/src/release/tar.ts +195 -0
  194. package/src/release/victoria.test.ts +108 -0
  195. package/src/release/victoria.ts +154 -0
  196. package/src/{proxmox → secrets}/write-only.ts +6 -0
  197. /package/dist/{proxmox → secrets}/write-only.d.ts +0 -0
  198. /package/src/{proxmox → secrets}/write-only.test.ts +0 -0
@@ -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,154 @@
1
+ # Release binaries — `@homeflare/alchemy/release`
2
+
3
+ Status: active
4
+ Verified: 2026-09-22
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', { programArguments: [vmalert.path, '--httpListenAddr=127.0.0.1:8880'], … });
41
+ ```
42
+
43
+ - **`catalogBinary()` runs in your stack program.** A version, package,
44
+ platform or binary the data set does not pin throws `BinaryRefused` there,
45
+ so `alchemy plan` fails before any resource is asked anything
46
+ (`plan.test.ts`: zero requests, zero host calls).
47
+ - **The directory is yours, one per version.** It must exist.
48
+ `catalogDirectory(root, …)` names it `<root>/<package>-<version>`. A version
49
+ bump is then a new path. Measured through the engine (`plan.test.ts`):
50
+ mkdir new, download, write new, remove old, rmdir old, in that order.
51
+ - ⛔ **A new pin at the same path is refused**, at plan when the path is
52
+ resolved and at apply otherwise, rather than overwriting a binary under the
53
+ daemon that runs it. 🔴 While the directory is still an Output, a re-pin of
54
+ the same bytes is planned as an `update`, never a `replace`: measured through
55
+ the engine, a `replace` onto an unmoved path took the identical file as the
56
+ new generation's, then deleted it with the old one, and reported success.
57
+ - **The job references `vmalert.path`**, not a string it built. That Output
58
+ orders the job after the binary. A version bump moves the path, so the plist
59
+ changes and the job restarts on the new binary before the old one is deleted.
60
+ - **Declare each path once.** vmalert-logs is vmalert with other flags: one
61
+ `ReleaseBinary`, referenced by both jobs.
62
+ - **One logical id per binary, one per versioned directory.** `plan.test.ts`
63
+ declares `ReleaseBinary('vmalert')` and `HostDirectory('vmutils-<version>')`:
64
+ a bump plans the binary as a `replace`, the new directory as a `create` and
65
+ the old one as a `delete`, and the engine removes the old binary before its
66
+ directory.
67
+ - **Nothing estate-specific is in the kit.** Root, owner, group and mode are
68
+ your props. The data sets hold vendor facts only.
69
+ - **One runner for all of it.** Pass the same `HostRunner` to
70
+ `releaseProviders()` as to the jobs. There is no default: a binary goes
71
+ wherever the runner reaches. It provides its own `FetchHttpClient.layer`.
72
+ - **Your own pins work too.** The props are plain data, so a stack may write
73
+ them itself. ⛔ They must be plain values in the stack program: a pin wired
74
+ from another resource's Output (a checksum file read at apply) is refused,
75
+ on a first deploy too (`apply-pins.test.ts`). ⚠️ A stack program that
76
+ fetches the file itself and passes plain strings cannot be told apart from
77
+ a reviewed pin; `catalogBinary()` over a reviewed data set is the path
78
+ that keeps them reviewed.
79
+
80
+ ## What reconcile does, in order
81
+
82
+ 1. **Validate every prop**, before any host or network call. Every pin must be a
83
+ plain, well-formed value, as the stack program DECLARED it: by now an Output
84
+ has resolved to a plausible string (`declared-pins.ts`).
85
+ 2. **Check the directory exists**, is not a symlink, and is not writable by
86
+ group or other (they could rename a file over the binary), before any download.
87
+ 3. **Observe the path.** A symlink or directory there is refused. A file this
88
+ resource does not own is refused without `--adopt`, even one holding the
89
+ pinned bytes: at plan the probe calls it `Unowned`, and at apply, when a
90
+ prop was an Output and the probe never ran, reconcile refuses it the same
91
+ way (`binary-claim.ts`). ⛔ A file with other bytes is refused under
92
+ `--adopt` too, at plan and at apply: adopting it would overwrite it in
93
+ place, and the plan would print that as `adopted`. Under `--adopt`, the
94
+ pinned bytes are kept: no download, and a write only to fix mode or owner.
95
+ ⚠️ A rename is not an adoption: declare it with `renamedFrom()`. Under
96
+ `--adopt` the new name takes the file, and the old name's delete leaves a
97
+ path another declaration installed in the same deploy (`adopt-parity.test.ts`).
98
+ 4. **Download** `https://github.com/<repo>/releases/download/<tag>/<asset>`,
99
+ into memory, never past the pinned size. Two binaries from one archive at
100
+ once share one download.
101
+ 5. **Verify the archive** against its pinned SHA-256, before unpacking a byte.
102
+ 6. **Unpack only the declared member**, streaming. The tar reader refuses the
103
+ whole archive for any unsafe entry, declared or not.
104
+ 7. **Verify the member** against its own pinned SHA-256.
105
+ 8. **Write** through `HostRunner.writeFileAtomic`, the one writer every file
106
+ resource uses: a temp file in the same directory, mode and owner set, then
107
+ rename. Under `sudoRunner`, one staged `install -S`, ⚠️ which sets the mode
108
+ just after its rename (measured: `0600` for under half a millisecond).
109
+ 9. **Read back** and compare. A create that does not read back, or whose
110
+ read-back fails, is removed.
111
+
112
+ A binary with the right bytes but the wrong mode or owner is re-written from
113
+ the bytes already on disk, re-hashed, without a download.
114
+
115
+ ⛔ A directory respelled to the same place (a symlinked parent, or case on
116
+ APFS) is an `update` that keeps the file, never a `replace`: the replace's
117
+ Phase 2 would delete the old path, which is the file (`launchd/file-identity.ts`).
118
+
119
+ ## Trust: why the digests are pinned in code
120
+
121
+ ★ **Pinned, not fetched at apply.** A checksum file comes from the same
122
+ account, release and channel as the archive, and every release checked so far
123
+ is mutable (`"immutable": false`). Whoever can swap an archive can swap its
124
+ checksum file, and a provider that trusts what it fetches would call the swap
125
+ verified. A pin moves only through a reviewed change, so a swapped asset is a
126
+ refusal. And a plan knows exactly which bytes it will install without the
127
+ network. Each data-set entry records the checksum file's URL, its own SHA-256
128
+ and the date the pins were copied.
129
+
130
+ ⚠️ **The trust root.** At apply it is the pins alone: TLS, GitHub's redirect,
131
+ its CDN or a proxy can make a download fail, never make other bytes pass. The
132
+ pins rest on one read of the GitHub release over TLS on the `recorded` date
133
+ (trust on first use), checked against GitHub's own asset `digest`, then on
134
+ review of that commit and the kit release the stack's lockfile resolves.
135
+ VictoriaMetrics publishes no signature, cosign bundle, SLSA provenance or
136
+ GitHub attestation for these archives: a checksum proves integrity, not
137
+ authorship.
138
+
139
+ ★ **What CI proves without a network** (`victoria.test.ts`) is consistency,
140
+ not provenance: each committed checksum file in `src/release/fixtures/victoria/`
141
+ hashes to the digest the data set records for it; the strict parser reads
142
+ them; the data set equals what it reads; every asset is in the committed
143
+ listing, and none is an `-enterprise`, `-cluster` or `vlutils` sibling.
144
+ ⚠️ That those recorded digests are GitHub's was measured once, not by CI. A
145
+ change that edits a fixture and its pins together passes; review is the gate.
146
+
147
+ ## Refusals
148
+
149
+ Every refusal, by where it is refused (the stack program, the plan, the apply,
150
+ the vendor's bytes, the read-back): [release-binary-refusals.md](./release-binary-refusals.md).
151
+
152
+ Data sets and adding a vendor: [release-binary-catalogs.md](./release-binary-catalogs.md).
153
+ Measurements and limits: [release-binary-measured.md](./release-binary-measured.md).
154
+ What alchemy-run/alchemy would need: [release-binary-upstream.md](./release-binary-upstream.md).
@@ -0,0 +1,164 @@
1
+ # Upstream conformance, family by family
2
+
3
+ Status: open ledger. Verified 2026-09-22 by reading `origin/main` `925454b` against
4
+ [provider-standard.md](./provider-standard.md) (`alchemy@2.0.0-beta.79`). Nothing in any
5
+ family was changed by this audit. Each row names the rule it breaks and the upstream
6
+ replacement or fix. A row is crossed off in the PR that fixes it, with that PR's evidence.
7
+ A decision marked **maintainer** is not an agent's to make.
8
+
9
+ ## Ranked findings
10
+
11
+ Findings are ranked by what they can break: runtime safety first, then contributability,
12
+ then tidiness.
13
+
14
+ 1. **`cloudflare/R2BucketLock` diverges on four rules.**
15
+ - `readLock` ends in `Effect.orDie` (S20).
16
+ - `reconcileLock` and `deleteLock` use `Effect.promise` (S19).
17
+ - `reconcileLock` skips the PUT whenever `output.rules` equals the declaration, without
18
+ reading the live lock (S10). So neither `--force` nor `drift --repair` can restore
19
+ lock rules that were removed out of band. ⚠️ Reasoned from the source, not measured
20
+ live.
21
+ - `accountId` is a prop (S15).
22
+
23
+ It is built on a second Cloudflare SDK, `cloudflare@4.5.0` (S23). Upstream
24
+ `Cloudflare.R2.Bucket` has lifecycle and CORS rules but no lock, so this is a real gap.
25
+ **Fix:** move to `@distilled.cloud/cloudflare/r2` `getBucketLock`/`putBucketLock`,
26
+ observe before the PUT, and resolve the account from `CloudflareEnvironment`. Then drop
27
+ the `cloudflare` peer. That makes it contributable as `lockRules` on `R2.Bucket`, or as
28
+ `Cloudflare.R2.BucketLock`. **Decision:** maintainer, on the upstream shape.
29
+
30
+ 2. **`forgejo/*` duplicates an upstream building block.** `client.ts` is a hand-rolled
31
+ Effect `HttpClient` client with a status-carrying `ForgejoError`, while
32
+ `@distilled.cloud/forgejo@1.0.0-rc.12` exists. That package's Smithy service version is
33
+ 16.0.3, the version this family measured (S21, S23). Credentials come from
34
+ `FORGEJO_TOKEN` at call time instead of an Auth provider (S24). `read` never answers
35
+ `Unowned` (H1). **Replacement:** the distilled SDK plus an `alchemy/Auth` provider.
36
+ Untyped errors become distilled patches. **Decision:** maintainer, because it changes
37
+ the error surface.
38
+ 3. **`cloudflare/MeshNode` duplicates `Cloudflare.Tunnel.WarpConnector`.** The divergence is
39
+ deliberate and documented in [mesh-node.md](./mesh-node.md): upstream persists the
40
+ connector token in state as a `Redacted` attribute, and it cannot create an HA node. The
41
+ type string `Cloudflare.MeshNode` sits inside upstream's own namespace (H14).
42
+ **Options:** contribute `ha` upstream and raise the token-in-state concern, then switch;
43
+ or keep the house resource as a recorded divergence. **Decision:** maintainer.
44
+ 4. **Unguarded `Bun.*`, and the `node:*` modules upstream names, in shipped provider code**
45
+ (S42). This is listed in [the Bun line](#the-bun-line). The exported `Bun.*` paths crash
46
+ under Node, which is the runtime this package promises its consumers. The `node:*` paths
47
+ load on Node but break the Effect-only rule.
48
+ 5. **`launchd`, `linux` and `caddy` have promise-based seams (H3).** `HostRunner` and
49
+ `CaddyAdmin` are plain `async` interfaces, with `node:child_process`, `node:fs/promises`
50
+ and `node:http` behind them, and failures are untyped `Error` (S19, S21). The stated
51
+ reason is that a consumer implements plain async functions. Upstream meets the same need
52
+ by letting a caller provide `FileSystem`, `ChildProcessSpawner` or `CommandExecutor`
53
+ layers, and `alchemy/Util/AtomicFile.writeFileAtomic` covers the temp-and-rename write.
54
+ ⚠️ Only in part. `local-runner.ts` creates its temp file with `O_EXCL`, mode 0600 and
55
+ `chown` before `chmod`, and it refuses a planted symlink. `writeFileAtomic` writes with
56
+ default flags and mode, `chmod`s afterwards, and never `chown`s.
57
+ **Decision:** maintainer. These families are also host-specific, so "house-only" is a
58
+ valid answer.
59
+ 6. **`openbao/*` has no upstream equivalent, and it conforms on the contract** (Effect
60
+ `HttpClient`, strict `Unowned`, retain). It diverges in three ways:
61
+ - 30 `Effect.die` sites in non-test source, several of them on `diff` and
62
+ `reconcile` paths (S20);
63
+ - 42 test files on `node:test` (S43);
64
+ - `Bao.*` type strings, where the vendor name is `OpenBao` (H14).
65
+
66
+ No distilled SDK exists. OpenBao serves an OpenAPI document, so a generated SDK in
67
+ distilled's shape is the S23 path. **Decision:** maintainer, on whether the kit authors
68
+ a distilled package.
69
+
70
+ 7. **`proxmox/*` is the model for S38.** Its types and constraints are generated from the
71
+ vendor schema, and a manifest records the version and sha256. It diverges in four ways:
72
+ - the generic factory's `read` returns plain attributes, so every family except `Lxc`
73
+ adopts silently, against the house's own H1;
74
+ - 27 `Effect.die` sites in non-test source (S20);
75
+ - 7 test files on `node:test`;
76
+ - `Pbs.*` type strings (H14).
77
+ 8. **`talos/*`.** Its `talosctl` calls go through `ChildProcessSpawner`, as upstream's Docker
78
+ provider does, which conforms. Its file handling uses `Bun.file`, `Bun.write`,
79
+ `Bun.YAML` and `node:fs` (S42), and it uses `Effect.promise` in 3 files (S19). The
80
+ kubeconfig it writes is meant to be consumed through upstream's
81
+ `Kubernetes.KubeConfig({ path })`.
82
+ 9. **`netbox/*` conforms mostly.** Its constraints are generated from NetBox 4.7.0's
83
+ OpenAPI, and it uses Effect `HttpClient`. Adopting first is documented as deliberate.
84
+ It diverges in its credentials: `NETBOX_TOKEN` is read at call time (S24).
85
+ 10. **Every family repeats `list: () => Effect.succeed([])`**, 30 times, and only
86
+ `R2BucketLock` and `MeshNode` declare `nuke`. The constructor already defaults `list`
87
+ (S12). **Fix:** write the reason where it differs, and declare `nuke: { skip: true }`
88
+ where nuke must never reach the object.
89
+ 11. **Resource JSDoc is not in upstream's generator format.** Zero files use `@resource`,
90
+ `**Example:**` or `### Section` (S31, H10). **Decision:** maintainer, because it moves
91
+ the house's glyph rationale into `//` comments.
92
+ 12. **Tests never use `alchemy/Test/Bun`.** Every lifecycle is proven against loopback
93
+ fakes (S28, H12). Live suites need a place to run, and that is a maintainer decision.
94
+
95
+ ## Conforms
96
+
97
+ - **`github/declareRepoPolicy`** composes upstream `GitHub.Repository` and
98
+ `GitHub.Ruleset`. Its one hazard is upstream's own: `Ruleset.read` returns `undefined`
99
+ without prior output, so a first deploy creates a duplicate
100
+ ([repo-policy.md](./repo-policy.md)). The fix belongs upstream: read by name and answer
101
+ `Unowned`.
102
+ - **`cloudflare/website.ts`** sets house defaults over upstream `Website.Astro` and
103
+ `Website.Vite`.
104
+ - **`ownership/*`** is built only on `AdoptPolicy`, `Stack`, `State` and `Artifacts`. Its
105
+ apply-time re-check (H2) exists because beta.79 skips the adoption probe while props hold
106
+ an Output. That fix also belongs in the engine upstream.
107
+ - **`verify/*`** (`hf-adopt-verify`) runs Alchemy's own planner. It exists because beta.79
108
+ labels every cold adoption as an update (H6).
109
+
110
+ ## The Bun line
111
+
112
+ These are non-test files under `src/` that the standard (S42) says must be portable. "Exp"
113
+ marks files exported from a subpath `index.ts`. `git grep` on `origin/main` finds 23 non-test
114
+ files under `src/` that call `Bun.*` or import `node:*`/`bun:*`. Six of them are outside
115
+ S42's scope:
116
+
117
+ - four loopback fakes (`caddy/fake-caddy.ts`, `openbao/fake-bao.ts`, `proxmox/fake-pve.ts`,
118
+ `proxmox/fake-pve-lxc.ts`), which are test-side under S44;
119
+ - `proxmox/provision-cli-fake.ts`, which is used only by tests;
120
+ - `verify/args.ts`, which belongs to the CLI and so is tooling under S43.
121
+
122
+ That leaves 17 files. The table lists them, plus `provision-cli-fake.ts`, which should move
123
+ out of `src/`. The six out-of-scope files still ship in the tarball's `src/`, but no export
124
+ reaches them.
125
+ ⚠️ The first version of this table said 16 files and left out `launchd/job-form.ts`.
126
+ ⚠️ Corrected 2026-09-22: 14 of the 17 break upstream's rule. `launchd/job-form.ts`,
127
+ `proxmox/write-only.ts` and `proxmox/pbs-notification-target-wire.ts` use only synchronous
128
+ `node:crypto` or `Buffer`. Upstream allows those inside `Effect.sync`
129
+ (`AGENTS.md@v2.0.0-beta.79#Workflow`), and 40 upstream `src/` files import `node:crypto`,
130
+ among them `Fly/Secret.ts` and `Railway/Variable.ts`. They were listed under a blanket
131
+ `node:*` ban that upstream does not have.
132
+
133
+ | file | API | exp | portable replacement |
134
+ | ---------------------------------------------------------- | -------------------------------------------- | --- | -------------------------------------------- |
135
+ | `openbao/cloudflare-roles-config.ts` | `Bun.YAML` | yes | parse on the tooling side; pass data in |
136
+ | `openbao/digest.ts` (14 importers) | `Bun.CryptoHasher` | — | `Crypto.digest` + hex; keep `canonical()` ⚠️ |
137
+ | `openbao/cloudflare-parity-snapshot.ts` | `Bun.file`, `Bun.Glob` | — | `FileSystem` + `Path` |
138
+ | `openbao/approle-login-form.ts` | `Bun.file` | — | `FileSystem.readFileString` |
139
+ | `openbao/approle-login-result.ts` | `Bun.inspect` | — | a plain formatter |
140
+ | `openbao/forgejo-bootstrap.ts` | `Bun.spawn` | — | unreferenced; `ChildProcessSpawner` or drop |
141
+ | `talos/credentials.ts` | `Bun.write/file/env/randomUUIDv7`, `node:fs` | — | `FileSystem.makeTempFileScoped` |
142
+ | `talos/kubeconfig.ts` | `Bun.file` | yes | `FileSystem` |
143
+ | `talos/talos-machine-config.ts` | `Bun.file` | yes | `FileSystem` |
144
+ | `talos/values.ts` | `Bun.YAML`, `node:crypto` | — | tooling-side parse; hash may stay |
145
+ | `launchd/local-runner.ts` | `node:child_process`, `node:fs/promises` | yes | H3 above |
146
+ | `launchd/sudo-stage.ts` | `node:fs/promises`, `node:os`, `node:path` | — | `FileSystem`, `Path` |
147
+ | `launchd/job-form.ts` | `node:crypto` (`createHash`) | — | allowed in `Effect.sync` |
148
+ | `linux/ssh-runner.ts` | `node:child_process`, `node:crypto` | yes | H3 above |
149
+ | `caddy/local-admin.ts` | `node:http` (unix socket) | yes | H3 above |
150
+ | `proxmox/write-only.ts`, `pbs-notification-target-wire.ts` | `node:crypto`, `node:buffer` | — | allowed in `Effect.sync` ⚠️ |
151
+ | `proxmox/provision-cli-fake.ts` | `Bun.spawn`, `node:fs` | — | test-only; move out of `src/` |
152
+
153
+ The ⚠️ rows:
154
+
155
+ - `openbao/digest.ts`: every `Bao.*` family persists this digest in state, so a swap must
156
+ hash the same bytes (`canonical()`, lowercase hex), or every row plans an update.
157
+ `Crypto.digest` from `effect/Crypto` is in every stack's services and fails with a typed
158
+ error; `alchemy/Util/sha256` wraps WebCrypto in `Effect.promise`.
159
+ - `proxmox/write-only.ts`: a salted scrypt seal with `timingSafeEqual`. No Alchemy helper
160
+ replaces it. A plain `sha256` would make a write-only secret cheap to brute-force from
161
+ state, and it would change the persisted seal format.
162
+
163
+ Test runners: 51 files import `node:test`/`node:assert` (openbao 42, proxmox 7, forgejo 1,
164
+ talos 1), and 126 import `bun:test`. S43 says `bun:test`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homeflare/alchemy",
3
- "version": "0.19.1",
3
+ "version": "0.21.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
+ });