@homeflare/alchemy 0.21.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 (35) hide show
  1. package/README.md +3 -1
  2. package/dist/launchd/index.d.ts +2 -0
  3. package/dist/launchd/index.d.ts.map +1 -1
  4. package/dist/launchd/index.js +53 -1
  5. package/dist/launchd/index.js.map +4 -3
  6. package/dist/launchd/port-claims.d.ts +31 -0
  7. package/dist/launchd/port-claims.d.ts.map +1 -0
  8. package/dist/release/catalog.d.ts +37 -0
  9. package/dist/release/catalog.d.ts.map +1 -1
  10. package/dist/release/index.d.ts +2 -1
  11. package/dist/release/index.d.ts.map +1 -1
  12. package/dist/release/index.js +75 -15
  13. package/dist/release/index.js.map +5 -4
  14. package/dist/release/openbao.d.ts +35 -0
  15. package/dist/release/openbao.d.ts.map +1 -0
  16. package/docs/launchd-ports.md +109 -0
  17. package/docs/launchd.md +5 -0
  18. package/docs/release-binary-catalogs.md +32 -7
  19. package/docs/release-binary-openbao.md +151 -0
  20. package/docs/release-binary.md +13 -2
  21. package/package.json +1 -1
  22. package/src/launchd/index.ts +2 -0
  23. package/src/launchd/port-claims.test.ts +124 -0
  24. package/src/launchd/port-claims.ts +102 -0
  25. package/src/launchd/providers.test.ts +3 -0
  26. package/src/release/catalog.test.ts +71 -0
  27. package/src/release/catalog.ts +68 -5
  28. package/src/release/fixtures/openbao/checksums.txt +83 -0
  29. package/src/release/fixtures/openbao/checksums.txt.gpgsig +0 -0
  30. package/src/release/fixtures/openbao/darwin-assets.txt +12 -0
  31. package/src/release/fixtures/openbao/openbao-gpg-pub-20240618.asc +109 -0
  32. package/src/release/index.ts +3 -0
  33. package/src/release/openbao.test.ts +120 -0
  34. package/src/release/openbao.ts +86 -0
  35. package/src/release/providers.test.ts +1 -0
@@ -0,0 +1,109 @@
1
+ # Port collisions, before a job is declared — `claimPorts()`
2
+
3
+ Status: active
4
+ Verified: 2026-09-23
5
+
6
+ `claimPorts(claims)` refuses two of a stack's own jobs sharing one port number, in the stack's own
7
+ program, before `alchemy plan` asks a single provider anything. It is not a provider and it is not
8
+ a `LaunchdJob` prop — it is a plain function a host stack calls itself, the same shape as
9
+ [`catalogBinary()`](../src/release/catalog.ts) (see [release-binary-upstream.md](./release-binary-upstream.md)).
10
+
11
+ ```ts
12
+ import { claimPorts } from '@homeflare/alchemy/launchd';
13
+
14
+ claimPorts([
15
+ { owner: 'vmalert', port: 8880 },
16
+ { owner: 'vmalert-logs', port: 8881, address: '127.0.0.1' },
17
+ ]);
18
+ // ...then declare the jobs themselves, as normal.
19
+ ```
20
+
21
+ ## Why the stack program, not a provider
22
+
23
+ Two structural facts about Alchemy 2.0.0-beta.79 rule out a provider-side check:
24
+
25
+ - **A provider never diffs a first `create` whose props still hold an unresolved `Output`.**
26
+ Every mini job's `programArguments` carries a `HostFile` config digest as an `Output`, so a
27
+ check living in `LaunchdJob`'s `diff` would be skipped on exactly the deploy that matters most —
28
+ the first one.
29
+ - **A provider never sees its sibling resources.** Nothing in the `LaunchdJob` handlers can read
30
+ what port another declared job holds; the stack program is the only place that sees the whole
31
+ set at once.
32
+
33
+ So `claimPorts` runs where the stack itself calls it, ordered however the stack likes (typically
34
+ right before the jobs it describes), and fails the **plan** — nothing is fetched, written, or
35
+ `launchctl`'d — if two claims collide.
36
+
37
+ ## Why it keys on the port number alone
38
+
39
+ `house/nix/homeflare-config/hosts/macmini/modules/lib-ports.nix` (a different repo —
40
+ `homeflare/homeflare`, read at `92cff7f`, 2026-09-23) is the check this replaces for a
41
+ kit-declared job. Its `assertNoCollision` is:
42
+
43
+ ```nix
44
+ dupes = lib.subtractLists (lib.unique vals) vals; # vals = lib.attrValues ports
45
+ ```
46
+
47
+ — every port **value** in the registry, address and protocol never read. `portClaimProblems`
48
+ reproduces that rule rather than a looser one, because that is the strictness the registry already
49
+ enforces across the whole estate, and a kit-side check that disagreed with it would be a second,
50
+ inconsistent source of truth.
51
+
52
+ ### The measured bind table
53
+
54
+ What that trades away is real: the OS itself is looser than the registry. Measured on the mini
55
+ (Darwin 27.2.0) with `/usr/bin/python3` sockets, `SO_REUSEADDR` set on both sides, loopback high
56
+ ports — first on 2026-09-23 at 47913–47917, **re-measured independently the same day** at
57
+ 47923–47927 with the same result:
58
+
59
+ | first bind | second bind | outcome |
60
+ | ------------------- | ----------- | ------------------------ |
61
+ | `127.0.0.1` | `127.0.0.1` | **refused** — EADDRINUSE |
62
+ | `0.0.0.0` | `127.0.0.1` | both bind |
63
+ | `127.0.0.1` | `0.0.0.0` | both bind |
64
+ | `[::]` (`V6ONLY=0`) | `127.0.0.1` | both bind |
65
+ | `[::1]` | `127.0.0.1` | both bind |
66
+
67
+ Only an **exact** address+port duplicate fails at bind time; a wildcard next to a specific address
68
+ on the same port silently splits traffic between two listeners instead of refusing. `claimPorts`
69
+ refuses that pairing anyway — the port number is the key, exactly like the registry — because
70
+ "the OS allowed it" is not the same claim as "the estate meant two things to share a port."
71
+
72
+ - **Every mini daemon this affects is Go**, and Go sets `SO_REUSEADDR` on **every** darwin
73
+ listener unconditionally: `setDefaultListenerSockopts` in `net/sockopt_bsd.go` (Go 1.26.5,
74
+ installed at `/opt/homebrew/Cellar/go/1.26.5`, `//go:build darwin || …`) —
75
+ `syscall.SetsockoptInt(s, syscall.SOL_SOCKET, syscall.SO_REUSEADDR, 1)`, no opt-out. The
76
+ Victoria family, Prometheus-family exporters and OpenBao are all Go, so the table above is their
77
+ actual behaviour, not a worst case.
78
+ - **vector is Rust.** Whether it also sets `SO_REUSEADDR` by default is **unmeasured** — say so
79
+ rather than assume Go's default carries over.
80
+ - **launchd itself has no port concept to collide on.** `man 5 launchd.plist`'s only socket-shaped
81
+ key is `Sockets` (launch-on-demand socket activation via `launch_activate_socket(3)`), and no
82
+ `LaunchdJobProps` today exposes it — a daemon binds its own port itself, launchd never mediates
83
+ it. So this check is squarely about daemons colliding with each other at bind time, not about
84
+ anything launchd enforces.
85
+
86
+ ## What it cannot see
87
+
88
+ - **A job the stack does not pass.** `claimPorts` takes exactly the array a stack builds and
89
+ hands it; there is no argv-parsing, no config-file reading and no host inspection here to
90
+ recover a port a stack forgot to include. Leaving a job's claim out of the list is invisible to
91
+ this check, the same way a port left out of `lib-ports.nix` was invisible on 2026-09-02.
92
+ - **A daemon still on Nix**, unless the migrating stack also passes that daemon's port as a claim
93
+ of its own (owner naming the Nix job, so the message is legible during a cutover). Until a job
94
+ moves off Nix, `lib-ports.nix` is still the thing keeping it collision-free; `claimPorts` only
95
+ starts covering a port once a kit stack declares the job that binds it.
96
+ - **Two protocols of the same port number.** Keying on the port alone means a daemon on `tcp/9094`
97
+ and another on `udp/9094` would be refused as a collision, same as the Nix registry. No mini job
98
+ does that today (checked against the registry at `92cff7f`); if one needs to, the key widens
99
+ deliberately, in its own change — not silently here.
100
+
101
+ ## The error is a defect, not a typed channel
102
+
103
+ `claimPorts` throws `PortRefused` (a `Data.TaggedError`), and like `catalogBinary()`, that throw
104
+ inside a stack program's `Effect.gen` body is a **defect** — `Effect.catchTag('PortRefused', …)`
105
+ never sees it, only `Effect.runPromiseExit` plus `Cause.hasDies`/`Cause.squash`. This is the exact
106
+ gap [release-binary-upstream.md](./release-binary-upstream.md) names as gap 10 for
107
+ `catalogBinary()`; it applies here unchanged because it is the same shape of function in the same
108
+ kind of caller, not a new hole. `port-claims.test.ts` measures it the same way
109
+ `error-channel.test.ts` measures `catalogBinary()`'s.
package/docs/launchd.md CHANGED
@@ -165,6 +165,11 @@ Nix job is booted out first. Per job:
165
165
  - **Rolling back** is the same three steps reversed: destroy (or remove) the resource, restore the
166
166
  job in the Nix config, activate.
167
167
 
168
+ ## Port collisions
169
+
170
+ `claimPorts()` refuses two of a stack's own jobs on one port, in the stack program, before any
171
+ job is declared: [launchd-ports.md](./launchd-ports.md).
172
+
168
173
  ## The runner
169
174
 
170
175
  `HostRunner` (see `src/launchd/runner.ts`) is `exec(argv)` (never through a shell), `readFile`,
@@ -1,7 +1,7 @@
1
1
  # Release binaries — the data sets, and adding one
2
2
 
3
3
  Status: active
4
- Verified: 2026-09-22
4
+ Verified: 2026-09-23
5
5
 
6
6
  `Release.Binary` takes one pinned archive as props and knows no vendor. What
7
7
  each vendor published lives in a **data set** beside it (`src/release/<vendor>.ts`),
@@ -9,7 +9,7 @@ typed `ReleaseCatalog`, and `catalogBinary(dataSet, request)` turns one entry
9
9
  into props. This page is what is in the data sets, and the rule for adding to
10
10
  them. The guide is [release-binary.md](./release-binary.md).
11
11
 
12
- ## `VICTORIA_RELEASES` — the only data set today
12
+ ## `VICTORIA_RELEASES`
13
13
 
14
14
  | package | version | asset (darwin-arm64) | binaries (installed ← member) |
15
15
  | ------------------ | ------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
@@ -36,6 +36,31 @@ change that re-pins both digest layers.
36
36
  A stack that keeps its own digest slots (the mini's `PINS`) can compare them
37
37
  with `catalogBinary(...).sha256` in its own tests: two witnesses, one source.
38
38
 
39
+ ## `OPENBAO_RELEASES` — the first data set with a `computed` member
40
+
41
+ | package | version | asset (darwin_arm64) | binaries (installed ← member) |
42
+ | --------- | ------- | ----------------------------------- | ----------------------------------------------------------- |
43
+ | `openbao` | 2.6.2 | `openbao_2.6.2_darwin_arm64.tar.gz` | `bao` ← `bao` (member digest: `computed`, ours — see below) |
44
+
45
+ Walked down 2026-09-23, every command in
46
+ [release-binary-openbao.md](./release-binary-openbao.md).
47
+
48
+ - ★ **Platform key is `darwin_arm64`, underscore** — OpenBao's own asset
49
+ spelling, not Victoria's `darwin-arm64`. Each data set spells the platform
50
+ the way that vendor's asset names do; nothing here picks one estate-wide
51
+ convention (`openbao.ts`'s header has the rule and the one-line change if a
52
+ reviewer wants it unified).
53
+ - ★ **The member digest is `computed`, not `members`.** `checksums.txt` lists
54
+ archives and every `.sbom.json`, never `bao` — the case this page used to
55
+ call out as "that field does not exist yet". It exists now: `catalogBinary`,
56
+ `catalogProblems` and `identifyBinary` all read `members` first, then
57
+ `computed`, and refuse a member pinned in both (`catalog.ts`).
58
+ - ★ **GPG checked, Sigstore read but not verified.** `gpgv` against the key
59
+ published at openbao.org confirmed the signature over `checksums.txt`; the
60
+ key's own fingerprint matches OpenBao's install docs. `cosign` is not on
61
+ the mini, so the Sigstore bundle's workflow identity is recorded as
62
+ unverified, not as a passing check.
63
+
39
64
  ## Adding a version of a vendor already here
40
65
 
41
66
  Walk it down from the vendor, never from memory:
@@ -66,9 +91,9 @@ its data module's header with the date:
66
91
  estate census found (landscape PR 88) list **archives only**: Prometheus's
67
92
  `sha256sums.txt`, vector's `-SHA256SUMS`, pyroscope's and OpenBao's
68
93
  `checksums.txt`. For those the member digest is not a vendor fact. It is
69
- computed from a verified archive, and the data set must say so rather than
70
- file it beside the vendor's lines. That field does not exist yet; it arrives
71
- with its first consumer.
94
+ computed from a verified archive, and the data set says so under
95
+ `computed` rather than filing it beside the vendor's `members` lines
96
+ (`catalog.ts`; `OPENBAO_RELEASES` above is the first consumer).
72
97
  - **Its binary's linkage** (`otool -L` on the extracted member, or `ldd`).
73
98
  Vendor binaries were measured self-contained **only for the five Victoria
74
99
  ones** (system libraries and `/usr/lib/libresolv.9.dylib` only). That is not
@@ -84,8 +109,8 @@ where `-version` prints, if anything ever runs it (Victoria's goes to stderr).
84
109
  The queue, from the census, each **not yet walked for this resource**:
85
110
  alertmanager, blackbox_exporter, node_exporter and postgres_exporter
86
111
  (Prometheus `sha256sums.txt`), unpoller (detached `.sig`; 5.2.7 renames
87
- `darwin_arm64` to `darwin_all`), pyroscope, vector, and `bao` (OpenBao, with
88
- GPG and Sigstore to check).
112
+ `darwin_arm64` to `darwin_all`), pyroscope and vector. `bao` (OpenBao) is
113
+ walked — see `OPENBAO_RELEASES` above.
89
114
 
90
115
  ⚠️ REASONED NOT MEASURED: the Prometheus-family archives wrap their files in a
91
116
  directory, and `tar.ts` refuses a directory entry (and any PAX header) for the
@@ -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.
@@ -1,7 +1,7 @@
1
1
  # Release binaries — `@homeflare/alchemy/release`
2
2
 
3
3
  Status: active
4
- Verified: 2026-09-22
4
+ Verified: 2026-09-23
5
5
 
6
6
  `Release.Binary` (`ReleaseBinary`) installs one binary out of a pinned release
7
7
  archive, verified twice: the archive against its pinned SHA-256 before anything
@@ -37,9 +37,20 @@ const vmalert = yield* ReleaseBinary('vmalert', {
37
37
  directory: dir.path, // ★ an Output: the engine creates the directory first
38
38
  owner: 0, group: 0, mode: 0o555,
39
39
  });
40
- yield* LaunchdJob('vmalert', { programArguments: [vmalert.path, '--httpListenAddr=127.0.0.1:8880'], … });
40
+ yield* LaunchdJob('vmalert-job', { programArguments: [vmalert.path, '--httpListenAddr=127.0.0.1:8880'], … });
41
41
  ```
42
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
+
43
54
  - **`catalogBinary()` runs in your stack program.** A version, package,
44
55
  platform or binary the data set does not pin throws `BinaryRefused` there,
45
56
  so `alchemy plan` fails before any resource is asked anything
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homeflare/alchemy",
3
- "version": "0.21.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",
@@ -21,6 +21,8 @@ export type { LocalRunnerOptions } from './local-runner.ts';
21
21
  export { localRunner } from './local-runner.ts';
22
22
  export type { PlistDict, PlistValue } from './plist.ts';
23
23
  export { PlistError, renderPlist } from './plist.ts';
24
+ export type { PortClaim } from './port-claims.ts';
25
+ export { PortRefused, claimPorts, portClaimProblems } from './port-claims.ts';
24
26
  export { launchdProviders } from './providers.ts';
25
27
  export type { ExecResult, FileStat, HostRunner, HostUser, WriteOptions } from './runner.ts';
26
28
  export { HostRunnerService, canActAsRoot, hostRunnerLayer } from './runner.ts';
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Port collisions, pure and through the engine. Every refusal here happens in the stack's own
3
+ * program (an ordinary `throw` before any resource is even declared), so plan.test.ts's proof for
4
+ * `catalogBinary()` — that the engine never runs a provider or touches a host once the body throws
5
+ * — is re-proved below for `claimPorts`, not assumed to carry over.
6
+ */
7
+ import { describe, expect, test } from 'bun:test';
8
+ import * as Effect from 'effect/Effect';
9
+ import { fakeStack } from '../openbao/fake-stack.ts';
10
+ import { fakeRunner } from './fake-runner.ts';
11
+ import { LaunchdJob } from './job.ts';
12
+ import { type PortClaim, PortRefused, claimPorts, portClaimProblems } from './port-claims.ts';
13
+ import { launchdProviders } from './providers.ts';
14
+ import type { HostRunner } from './runner.ts';
15
+
16
+ describe('portClaimProblems', () => {
17
+ test.each([
18
+ [
19
+ 'a port that is not an integer 1–65535 (too low)',
20
+ [{ owner: 'vector', port: 0 }],
21
+ 'port must be an integer from 1 to 65535',
22
+ ],
23
+ [
24
+ 'a port that is not an integer 1–65535 (too high)',
25
+ [{ owner: 'vector', port: 65536 }],
26
+ 'port must be an integer from 1 to 65535',
27
+ ],
28
+ [
29
+ 'a port that is not an integer 1–65535 (fractional)',
30
+ [{ owner: 'vector', port: 9094.5 }],
31
+ 'port must be an integer from 1 to 65535',
32
+ ],
33
+ ['an empty owner', [{ owner: '', port: 9094 }], 'has an empty owner'],
34
+ [
35
+ 'one owner claiming a port twice',
36
+ [
37
+ { owner: 'vector', port: 9094 },
38
+ { owner: 'vector', port: 9094, address: '127.0.0.1' },
39
+ ],
40
+ 'vector claims port 9094 more than once',
41
+ ],
42
+ [
43
+ 'two owners on one port, naming both owners and both addresses',
44
+ [
45
+ { owner: 'vector', port: 9094, address: '0.0.0.0' },
46
+ { owner: 'victoria-logs', port: 9094, address: '127.0.0.1' },
47
+ ],
48
+ 'port 9094 is claimed by more than one owner: vector (0.0.0.0) and victoria-logs (127.0.0.1)',
49
+ ],
50
+ ] satisfies Array<[string, PortClaim[], string]>)('refuses %s', (_, claims, message) => {
51
+ expect(portClaimProblems(claims).join('; ')).toContain(message);
52
+ expect(() => claimPorts(claims)).toThrow(message);
53
+ });
54
+
55
+ test('distinct ports return no problems', () => {
56
+ const claims: PortClaim[] = [
57
+ { owner: 'vector', port: 9094 },
58
+ { owner: 'victoria-logs', port: 9428, address: '127.0.0.1' },
59
+ ];
60
+ expect(portClaimProblems(claims)).toEqual([]);
61
+ expect(() => claimPorts(claims)).not.toThrow();
62
+ });
63
+
64
+ // ★ MEASURED (docs/launchd-ports.md): on macOS both of these actually bind — SO_REUSEADDR plus
65
+ // an address mismatch lets a wildcard and a specific address share one port at the OS level.
66
+ // The check refuses it anyway: the key is the port NUMBER alone, like lib-ports.nix.
67
+ test('0.0.0.0:9094 against 127.0.0.1:9094 is refused, though the OS would let both bind', () => {
68
+ const problems = portClaimProblems([
69
+ { owner: 'vector', port: 9094, address: '0.0.0.0' },
70
+ { owner: 'victoria-logs', port: 9094, address: '127.0.0.1' },
71
+ ]);
72
+ expect(problems).toEqual([
73
+ 'port 9094 is claimed by more than one owner: vector (0.0.0.0) and victoria-logs (127.0.0.1)',
74
+ ]);
75
+ });
76
+
77
+ test('claimPorts throws PortRefused carrying the house refusal sentence', () => {
78
+ let caught: unknown;
79
+ try {
80
+ claimPorts([
81
+ { owner: 'a', port: 9094 },
82
+ { owner: 'b', port: 9094 },
83
+ ]);
84
+ } catch (cause) {
85
+ caught = cause;
86
+ }
87
+ expect(caught).toBeInstanceOf(PortRefused);
88
+ expect((caught as Error).message).toMatch(/^Launchd ports: .* Nothing was declared\.$/);
89
+ });
90
+ });
91
+
92
+ describe('through the engine', () => {
93
+ test('a collision fails the plan before any provider or host call', async () => {
94
+ const fake = fakeRunner({ euid: 0 });
95
+ const events: string[] = [];
96
+ const runner: HostRunner = {
97
+ ...fake.runner,
98
+ exec: (argv) => {
99
+ events.push(`exec ${argv.join(' ')}`);
100
+ return fake.runner.exec(argv);
101
+ },
102
+ writeFileAtomic: (path, bytes, options) => {
103
+ events.push(`write ${path}`);
104
+ return fake.runner.writeFileAtomic(path, bytes, options);
105
+ },
106
+ };
107
+ const stack = fakeStack(launchdProviders(runner), {}, 'PortClaimsStack');
108
+ const body = Effect.gen(function* () {
109
+ claimPorts([
110
+ { owner: 'vector', port: 9094 },
111
+ { owner: 'victoria-logs', port: 9094 },
112
+ ]);
113
+ // Never reached: proves the collision is caught before this job — or any provider/host
114
+ // call it would make — runs at all.
115
+ yield* LaunchdJob('never-created', {
116
+ domain: 'system',
117
+ label: 'com.example.never-created',
118
+ programArguments: ['/usr/local/bin/never-created'],
119
+ });
120
+ });
121
+ await expect(stack.deploy(body)).rejects.toThrow('Nothing was declared');
122
+ expect(events).toEqual([]);
123
+ });
124
+ });
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Port collision refusal for a stack's own launchd jobs — pure: no host, no network, and
3
+ * runtime-portable (no `Bun.*`, no `node:*`), so a future Linux consumer (systemd units, under
4
+ * `/linux`) can import it unchanged.
5
+ *
6
+ * ★ MIRRORS house/nix's REGISTRY, NOT A NEW RULE. `lib-ports.nix` (house @ 92cff7f) collides on
7
+ * `lib.attrValues ports` alone — `dupes = lib.subtractLists (lib.unique vals) vals` — keying on
8
+ * the PORT NUMBER by itself; address and protocol are never read. `portClaimProblems` below keys
9
+ * the same way on purpose: it replaces that check for the jobs a kit stack declares, and a looser
10
+ * or stricter rule would silently diverge from the registry it takes over from. What that trades
11
+ * away — a wildcard bind beside a specific one on the same port both succeed, silently splitting
12
+ * traffic — is measured in docs/launchd-ports.md, not assumed.
13
+ * ★ IN THE STACK PROGRAM, NOT A PROVIDER — same shape as `catalogBinary()` (../release/catalog.ts).
14
+ * Two reasons, both structural to Alchemy 2.0.0-beta.79: Alchemy never diffs a first `create`
15
+ * whose props still hold an unresolved `Output`, and every mini job's `programArguments` carries
16
+ * a `HostFile` config digest as an `Output` — so a provider-side check would miss the very first
17
+ * deploy of every job. And a provider never sees its sibling resources, so it could not compare
18
+ * one job's port against another's even if props were resolved. A plain function called from the
19
+ * stack's own `Effect.gen` body sees every claim the stack passes, before anything is applied.
20
+ * ⚠️ IT SEES ONLY WHAT THE STACK PASSES. A job the stack forgets to add to its claim list is
21
+ * invisible to this check — there is no argv-parsing or host inspection here to catch that, and
22
+ * adding one would be guessing at a job's own config shape. docs/launchd-ports.md says what this
23
+ * cannot see, in full.
24
+ */
25
+ import * as Data from 'effect/Data';
26
+
27
+ /** One job's claim on one port. `address` is informational only — the key is `port` alone. */
28
+ export interface PortClaim {
29
+ /** Whose claim this is, for the refusal message: a job id or label, never empty. */
30
+ readonly owner: string;
31
+ readonly port: number;
32
+ /** What the job binds, if the stack knows it. Omitted claims still collide by port number. */
33
+ readonly address?: string;
34
+ }
35
+
36
+ const describeAddress = (address: string | undefined): string => address ?? 'unspecified address';
37
+
38
+ const describeOwner = (owner: string): string => (owner === '' ? '(empty owner)' : owner);
39
+
40
+ /**
41
+ * Everything wrong with a set of claims; empty when none collide. ★ KEYS ON `port` ALONE, exactly
42
+ * like `lib-ports.nix`'s `assertNoCollision` — see the file header for why.
43
+ */
44
+ export const portClaimProblems = (claims: readonly PortClaim[]): string[] => {
45
+ const found: string[] = [];
46
+ const byPort = new Map<number, PortClaim[]>();
47
+ for (const claim of claims) {
48
+ if (!Number.isSafeInteger(claim.port) || claim.port < 1 || claim.port > 65535) {
49
+ found.push(
50
+ `${describeOwner(claim.owner)}: port must be an integer from 1 to 65535, got ${String(claim.port)}`,
51
+ );
52
+ }
53
+ if (claim.owner === '') {
54
+ found.push(`a claim on port ${String(claim.port)} has an empty owner`);
55
+ }
56
+ const list = byPort.get(claim.port) ?? [];
57
+ list.push(claim);
58
+ byPort.set(claim.port, list);
59
+ }
60
+ for (const [port, list] of byPort) {
61
+ if (list.length < 2) continue;
62
+ // One owner naming the same port more than once — flagged the first time it repeats.
63
+ const seen = new Set<string>();
64
+ for (const claim of list) {
65
+ if (claim.owner === '') continue; // already reported above
66
+ if (seen.has(claim.owner)) {
67
+ found.push(`${claim.owner} claims port ${String(port)} more than once`);
68
+ }
69
+ seen.add(claim.owner);
70
+ }
71
+ // Two different owners on the same port number — the collision the registry exists to catch.
72
+ const distinctOwners = [...new Set(list.map((claim) => claim.owner).filter((o) => o !== ''))];
73
+ if (distinctOwners.length > 1) {
74
+ const described = list
75
+ .map((claim) => `${describeOwner(claim.owner)} (${describeAddress(claim.address)})`)
76
+ .join(' and ');
77
+ found.push(`port ${String(port)} is claimed by more than one owner: ${described}`);
78
+ }
79
+ }
80
+ return found;
81
+ };
82
+
83
+ /** The declaration itself: a bad port, an empty owner, or two owners sharing one port number. */
84
+ export class PortRefused extends Data.TaggedError('PortRefused')<{
85
+ readonly message: string;
86
+ }> {}
87
+
88
+ /**
89
+ * Refuse a colliding set of claims, or do nothing. ⛔ THROWS `PortRefused` — exactly like
90
+ * `catalogBinary()`, this is a DEFECT in a stack program's `Effect.gen` body, not a typed failure
91
+ * a caller can `Effect.catchTag` on: gap 10 of docs/release-binary-upstream.md applies here
92
+ * unchanged (measured the same way, in port-claims.test.ts). The plan still fails before anything
93
+ * is applied; the error itself is just not a typed channel yet.
94
+ */
95
+ export const claimPorts = (claims: readonly PortClaim[]): void => {
96
+ const problems = portClaimProblems(claims);
97
+ if (problems.length > 0) {
98
+ throw new PortRefused({
99
+ message: `Launchd ports: ${problems.join('; ')}. Nothing was declared.`,
100
+ });
101
+ }
102
+ };
@@ -212,11 +212,14 @@ describe('the barrel', () => {
212
212
  'LaunchdJob',
213
213
  'LaunchdJobProvider',
214
214
  'PlistError',
215
+ 'PortRefused',
215
216
  'SudoRefusedError',
216
217
  'canActAsRoot',
218
+ 'claimPorts',
217
219
  'hostRunnerLayer',
218
220
  'launchdProviders',
219
221
  'localRunner',
222
+ 'portClaimProblems',
220
223
  'renderPlist',
221
224
  // ★ The opt-in elevating runner; makeSudoRunner and the allowlist internals stay off.
222
225
  'sudoRunner',