@homeflare/alchemy 0.20.0 → 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 (172) 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.map +3 -3
  31. package/dist/linux/sudo-listing.d.ts +47 -0
  32. package/dist/linux/sudo-listing.d.ts.map +1 -0
  33. package/dist/proxmox/credential-literals.d.ts +1 -1
  34. package/dist/proxmox/credential-literals.d.ts.map +1 -1
  35. package/dist/proxmox/generated/pbs/nodes-node.d.ts +1 -1
  36. package/dist/proxmox/generated/pbs/nodes-node.d.ts.map +1 -1
  37. package/dist/proxmox/generated/pbs.d.ts +7 -0
  38. package/dist/proxmox/generated/pbs.d.ts.map +1 -1
  39. package/dist/proxmox/generated/pve.d.ts +7 -0
  40. package/dist/proxmox/generated/pve.d.ts.map +1 -1
  41. package/dist/proxmox/index.d.ts +1 -1
  42. package/dist/proxmox/index.d.ts.map +1 -1
  43. package/dist/proxmox/index.js +2 -2
  44. package/dist/proxmox/index.js.map +9 -9
  45. package/dist/proxmox/pbs-notification-target-form.d.ts +1 -1
  46. package/dist/proxmox/pbs-notification-target-form.d.ts.map +1 -1
  47. package/dist/proxmox/pbs-notification-target.d.ts +1 -1
  48. package/dist/proxmox/pbs-notification-target.d.ts.map +1 -1
  49. package/dist/release/archive.d.ts +11 -0
  50. package/dist/release/archive.d.ts.map +1 -0
  51. package/dist/release/binary-claim.d.ts +53 -0
  52. package/dist/release/binary-claim.d.ts.map +1 -0
  53. package/dist/release/binary-diff.d.ts +38 -0
  54. package/dist/release/binary-diff.d.ts.map +1 -0
  55. package/dist/release/binary-form.d.ts +118 -0
  56. package/dist/release/binary-form.d.ts.map +1 -0
  57. package/dist/release/binary-lifecycle.d.ts +55 -0
  58. package/dist/release/binary-lifecycle.d.ts.map +1 -0
  59. package/dist/release/binary-read.d.ts +11 -0
  60. package/dist/release/binary-read.d.ts.map +1 -0
  61. package/dist/release/binary.d.ts +68 -0
  62. package/dist/release/binary.d.ts.map +1 -0
  63. package/dist/release/catalog.d.ts +76 -0
  64. package/dist/release/catalog.d.ts.map +1 -0
  65. package/dist/release/checksums.d.ts +22 -0
  66. package/dist/release/checksums.d.ts.map +1 -0
  67. package/dist/release/declared-pins.d.ts +4 -0
  68. package/dist/release/declared-pins.d.ts.map +1 -0
  69. package/dist/release/download.d.ts +44 -0
  70. package/dist/release/download.d.ts.map +1 -0
  71. package/dist/release/fake-deploy.d.ts +48 -0
  72. package/dist/release/fake-deploy.d.ts.map +1 -0
  73. package/dist/release/fake-engine.d.ts +8 -0
  74. package/dist/release/fake-engine.d.ts.map +1 -0
  75. package/dist/release/fake-install.d.ts +39 -0
  76. package/dist/release/fake-install.d.ts.map +1 -0
  77. package/dist/release/fake-provider.d.ts +58 -0
  78. package/dist/release/fake-provider.d.ts.map +1 -0
  79. package/dist/release/fake-release.d.ts +95 -0
  80. package/dist/release/fake-release.d.ts.map +1 -0
  81. package/dist/release/index.d.ts +24 -0
  82. package/dist/release/index.d.ts.map +1 -0
  83. package/dist/release/index.js +1365 -0
  84. package/dist/release/index.js.map +35 -0
  85. package/dist/release/providers.d.ts +19 -0
  86. package/dist/release/providers.d.ts.map +1 -0
  87. package/dist/release/refused.d.ts +39 -0
  88. package/dist/release/refused.d.ts.map +1 -0
  89. package/dist/release/tar.d.ts +14 -0
  90. package/dist/release/tar.d.ts.map +1 -0
  91. package/dist/release/victoria.d.ts +36 -0
  92. package/dist/release/victoria.d.ts.map +1 -0
  93. package/dist/{proxmox → secrets}/write-only.d.ts.map +1 -1
  94. package/docs/release-binary-catalogs.md +113 -0
  95. package/docs/release-binary-measured.md +163 -0
  96. package/docs/release-binary-refusals.md +57 -0
  97. package/docs/release-binary-upstream.md +163 -0
  98. package/docs/release-binary.md +154 -0
  99. package/package.json +5 -1
  100. package/src/caddy/access-forward-auth.test.ts +63 -0
  101. package/src/caddy/access-forward-auth.ts +120 -0
  102. package/src/caddy/index.ts +10 -0
  103. package/src/caddy/providers.test.ts +4 -0
  104. package/src/cloudflare/mcp-server-api.ts +294 -0
  105. package/src/cloudflare/mcp-server-form.ts +219 -0
  106. package/src/index.ts +1 -0
  107. package/src/launchd/file-converge.ts +233 -0
  108. package/src/launchd/file-identity.ts +69 -0
  109. package/src/launchd/host-file-alias.test.ts +65 -0
  110. package/src/launchd/host-file-form.ts +2 -1
  111. package/src/launchd/host-file-lifecycle.ts +25 -144
  112. package/src/launchd/host-file.ts +8 -2
  113. package/src/launchd/local-runner.test.ts +12 -1
  114. package/src/launchd/local-runner.ts +2 -0
  115. package/src/launchd/runner.ts +17 -2
  116. package/src/launchd/sudo-runner.ts +11 -0
  117. package/src/linux/sudo-listing.ts +122 -0
  118. package/src/proxmox/credential-literals.ts +1 -1
  119. package/src/proxmox/generated/pbs/nodes-node.ts +1 -1
  120. package/src/proxmox/generated/pbs.ts +7 -0
  121. package/src/proxmox/generated/pve.ts +7 -0
  122. package/src/proxmox/index.ts +1 -1
  123. package/src/proxmox/pbs-notification-target-form.ts +1 -1
  124. package/src/proxmox/pbs-notification-target-lifecycle.ts +1 -1
  125. package/src/proxmox/pbs-notification-target-wire.ts +1 -1
  126. package/src/proxmox/pbs-notification-target.ts +1 -1
  127. package/src/release/adopt-parity.test.ts +106 -0
  128. package/src/release/apply-pins.test.ts +128 -0
  129. package/src/release/archive.ts +94 -0
  130. package/src/release/binary-alias.test.ts +109 -0
  131. package/src/release/binary-claim.ts +80 -0
  132. package/src/release/binary-claims.test.ts +47 -0
  133. package/src/release/binary-diff.ts +73 -0
  134. package/src/release/binary-form.ts +229 -0
  135. package/src/release/binary-lifecycle.test.ts +90 -0
  136. package/src/release/binary-lifecycle.ts +202 -0
  137. package/src/release/binary-read.ts +55 -0
  138. package/src/release/binary-refusals.test.ts +192 -0
  139. package/src/release/binary-sudo.test.ts +93 -0
  140. package/src/release/binary.ts +181 -0
  141. package/src/release/catalog.test.ts +96 -0
  142. package/src/release/catalog.ts +175 -0
  143. package/src/release/checksums.test.ts +35 -0
  144. package/src/release/checksums.ts +43 -0
  145. package/src/release/declared-pins.ts +39 -0
  146. package/src/release/download.test.ts +138 -0
  147. package/src/release/download.ts +110 -0
  148. package/src/release/error-channel.test.ts +69 -0
  149. package/src/release/fake-deploy.ts +87 -0
  150. package/src/release/fake-engine.ts +50 -0
  151. package/src/release/fake-install.ts +59 -0
  152. package/src/release/fake-provider.ts +68 -0
  153. package/src/release/fake-release.ts +185 -0
  154. package/src/release/fixtures/victoria/darwin-arm64-assets.txt +22 -0
  155. package/src/release/fixtures/victoria/victoria-logs-darwin-arm64-v1.52.0_checksums.txt +2 -0
  156. package/src/release/fixtures/victoria/victoria-metrics-darwin-arm64-v1.151.0_checksums.txt +2 -0
  157. package/src/release/fixtures/victoria/victoria-traces-darwin-arm64-v0.10.0_checksums.txt +2 -0
  158. package/src/release/fixtures/victoria/vmutils-darwin-arm64-v1.151.0_checksums.txt +8 -0
  159. package/src/release/index.ts +41 -0
  160. package/src/release/plan.test.ts +189 -0
  161. package/src/release/provider-refusals.test.ts +48 -0
  162. package/src/release/providers.test.ts +174 -0
  163. package/src/release/providers.ts +24 -0
  164. package/src/release/recovery-read.test.ts +44 -0
  165. package/src/release/refused.ts +40 -0
  166. package/src/release/tar.test.ts +142 -0
  167. package/src/release/tar.ts +195 -0
  168. package/src/release/victoria.test.ts +108 -0
  169. package/src/release/victoria.ts +154 -0
  170. package/src/{proxmox → secrets}/write-only.ts +6 -0
  171. /package/dist/{proxmox → secrets}/write-only.d.ts +0 -0
  172. /package/src/{proxmox → secrets}/write-only.test.ts +0 -0
@@ -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).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homeflare/alchemy",
3
- "version": "0.20.0",
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
+ });
@@ -0,0 +1,120 @@
1
+ /**
2
+ * The Caddyfile half of "protect this route with Cloudflare Access": a `forward_auth` block
3
+ * pointing at a verifier that speaks the contract in `@homeflare/cloudflare/access-auth`.
4
+ *
5
+ * ★ A BUILDER, NOT A RESOURCE. Nothing here is created, adopted or destroyed — it is Caddyfile
6
+ * TEXT that a caller drops into the `caddyfile` prop of a `CaddyConfig` / `caddyWithFile()`.
7
+ * A resource would own a fragment of a config that `CaddyConfig` already owns whole, and two
8
+ * owners of one file is the drift this package exists to prevent.
9
+ *
10
+ * ⛔ WHY IT IS GENERATED RATHER THAN COPY-PASTED. Every field below is load-bearing, and the
11
+ * failure mode of getting one wrong is a route that looks protected and is not:
12
+ * · a missing `copy_headers` — the upstream gets no identity and either breaks or, worse,
13
+ * falls back to trusting a header the CLIENT set;
14
+ * · the verifier on anything but loopback — the JWT check becomes advisory, because the
15
+ * verifier itself can be reached and answered around;
16
+ * · `uri` pointing at a path the verifier does not route — every request 404s, which is
17
+ * non-2xx, which Caddy correctly treats as a denial, so the symptom is "Access is broken".
18
+ */
19
+
20
+ /** ⚠️ Kept in step with `granted()` in @homeflare/cloudflare/access-auth. */
21
+ const DEFAULT_COPY_HEADERS = ['X-Access-Email', 'X-Access-Sub'] as const;
22
+
23
+ export interface AccessForwardAuthProps {
24
+ /**
25
+ * Where the verifier listens, e.g. `127.0.0.1:9101`.
26
+ * ⛔ LOOPBACK OR A UNIX SOCKET. See `verifierProblems` — a routable verifier is not a check.
27
+ */
28
+ readonly verifier: string;
29
+ /** The path the verifier answers on. @default '/access/verify' */
30
+ readonly uri?: string;
31
+ /**
32
+ * Response headers copied from the verifier onto the upstream request.
33
+ * @default ['X-Access-Email', 'X-Access-Sub']
34
+ */
35
+ readonly copyHeaders?: readonly string[];
36
+ /** Lines placed inside the route BEFORE `forward_auth` — typically the upstream. */
37
+ readonly body?: readonly string[];
38
+ /** Indent applied to every emitted line. @default '\t' */
39
+ readonly indent?: string;
40
+ }
41
+
42
+ /**
43
+ * Problems that make a `forward_auth` block unsafe rather than merely wrong.
44
+ *
45
+ * ⛔ THE LOOPBACK RULE IS THE IMPORTANT ONE. If the verifier answers on a routable address, an
46
+ * attacker reaches the UPSTREAM directly and never involves Caddy at all — or reaches the
47
+ * verifier and reads identities out of it. The whole design assumes both the verifier and the
48
+ * upstream are bound to loopback and that Caddy is the only way in.
49
+ */
50
+ const LOOPBACK = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']);
51
+
52
+ /**
53
+ * The host part of a Caddy upstream address.
54
+ *
55
+ * ⚠️ NOT `split(':')[0]`. That returns `'['` for `[::1]:9101`, so an IPv6 loopback verifier
56
+ * was rejected as routable — caught by the test below, not by reading the code.
57
+ */
58
+ const hostOf = (verifier: string): string => {
59
+ const address = verifier.replace(/^https?:\/\//, '');
60
+ if (address.startsWith('unix/')) return 'unix';
61
+
62
+ // Bracketed IPv6 keeps its brackets; the port is whatever follows the closing one.
63
+ if (address.startsWith('[')) return address.slice(0, address.indexOf(']') + 1);
64
+
65
+ const colon = address.indexOf(':');
66
+ return colon === -1 ? address : address.slice(0, colon);
67
+ };
68
+
69
+ export const verifierProblems = (verifier: string): string[] => {
70
+ const found: string[] = [];
71
+ const host = hostOf(verifier.trim());
72
+
73
+ if (verifier.trim() === '') found.push('verifier is empty');
74
+ else if (host !== 'unix' && !LOOPBACK.has(host)) {
75
+ found.push(
76
+ `verifier must be loopback or a unix socket, got ${verifier} — a routable verifier can be bypassed`,
77
+ );
78
+ }
79
+ return found;
80
+ };
81
+
82
+ /**
83
+ * Render the `forward_auth` block for one protected route.
84
+ *
85
+ * ```ts
86
+ * const caddyfile = `vnc.example.com {
87
+ * ${accessForwardAuth({ verifier: '127.0.0.1:9101', body: ['reverse_proxy 127.0.0.1:6080'] })}
88
+ * }
89
+ * `;
90
+ * ```
91
+ *
92
+ * ⛔ ONE VERIFIER ENDPOINT PER ACCESS APPLICATION. The AUD is fixed in the verifier's own config,
93
+ * never carried in this request — so two applications mean two `uri`s (or two verifiers), not
94
+ * one endpoint told which app to check. An AUD the request can influence is the vulnerability.
95
+ *
96
+ * ⚠️ `forward_auth` COPIES THE VERIFIER'S RESPONSE BACK TO THE CLIENT on any non-2xx, which is why
97
+ * the verifier answers a bare 401 with no body: whatever it returns is what the browser sees.
98
+ */
99
+ export const accessForwardAuth = (props: AccessForwardAuthProps): string => {
100
+ const problems = verifierProblems(props.verifier);
101
+ if (problems.length > 0) throw new Error(`accessForwardAuth: ${problems.join('; ')}`);
102
+
103
+ const indent = props.indent ?? '\t';
104
+ const uri = props.uri ?? '/access/verify';
105
+ const headers = props.copyHeaders ?? DEFAULT_COPY_HEADERS;
106
+
107
+ const lines = [
108
+ ...(props.body ?? []),
109
+ `forward_auth ${props.verifier} {`,
110
+ `${indent}uri ${uri}`,
111
+ // ⚠️ `copy_headers` with a block, one field per line: the single-line form is also valid but
112
+ // diffs badly, and this list is exactly where a security review needs to read a change.
113
+ `${indent}copy_headers {`,
114
+ ...headers.map((header) => `${indent}${indent}${header}`),
115
+ `${indent}}`,
116
+ '}',
117
+ ];
118
+
119
+ return lines.map((line) => `${indent}${line}`).join('\n');
120
+ };
@@ -9,6 +9,16 @@
9
9
  * ⛔ NOTHING IS ADOPTED SILENTLY: a running config that is not the declared one needs `--adopt`.
10
10
  * Guide, order of file and load, adoption, `--resume` and secrets: docs/caddy.md.
11
11
  */
12
+ /**
13
+ * ★ `accessForwardAuth` is Caddyfile TEXT, not a resource: it renders the `forward_auth` block
14
+ * that puts a Cloudflare Access check in front of a route, paired with the verifier in
15
+ * `@homeflare/cloudflare/access-auth`. Guide: docs/access-auth.md.
16
+ */
17
+ export {
18
+ accessForwardAuth,
19
+ verifierProblems,
20
+ type AccessForwardAuthProps,
21
+ } from './access-forward-auth.ts';
12
22
  export type {
13
23
  CaddyAdmin,
14
24
  CaddyAdminListener,
@@ -220,10 +220,14 @@ describe('the barrel', () => {
220
220
  'CaddyConfigProvider',
221
221
  'CaddyUnreachableError',
222
222
  'DEFAULT_ADMIN_ADDRESS',
223
+ // Caddyfile TEXT for an Access-protected route, not a resource — see
224
+ // access-forward-auth.ts. It pairs with @homeflare/cloudflare/access-auth.
225
+ 'accessForwardAuth',
223
226
  'caddyAdminLayer',
224
227
  'caddyProviders',
225
228
  'caddyWithFile',
226
229
  'localCaddyAdmin',
230
+ 'verifierProblems',
227
231
  ]);
228
232
  });
229
233
  });