@homeflare/alchemy 0.21.0 → 0.23.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 (62) 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/proxmox/api-token-form.d.ts +12 -1
  9. package/dist/proxmox/api-token-form.d.ts.map +1 -1
  10. package/dist/proxmox/api-token.d.ts +8 -3
  11. package/dist/proxmox/api-token.d.ts.map +1 -1
  12. package/dist/proxmox/generated/pve/cluster-sdn-fabrics.d.ts +13 -13
  13. package/dist/proxmox/generated/pve/cluster-sdn-fabrics.d.ts.map +1 -1
  14. package/dist/proxmox/index.d.ts +9 -2
  15. package/dist/proxmox/index.d.ts.map +1 -1
  16. package/dist/proxmox/index.js +17 -9
  17. package/dist/proxmox/index.js.map +8 -7
  18. package/dist/proxmox/zfs-pool-spec.d.ts +27 -0
  19. package/dist/proxmox/zfs-pool-spec.d.ts.map +1 -0
  20. package/dist/proxmox/zfs-pool-write.d.ts +48 -7
  21. package/dist/proxmox/zfs-pool-write.d.ts.map +1 -1
  22. package/dist/proxmox/zfs-pool.d.ts +42 -20
  23. package/dist/proxmox/zfs-pool.d.ts.map +1 -1
  24. package/dist/release/catalog.d.ts +37 -0
  25. package/dist/release/catalog.d.ts.map +1 -1
  26. package/dist/release/index.d.ts +2 -1
  27. package/dist/release/index.d.ts.map +1 -1
  28. package/dist/release/index.js +75 -15
  29. package/dist/release/index.js.map +5 -4
  30. package/dist/release/openbao.d.ts +35 -0
  31. package/dist/release/openbao.d.ts.map +1 -0
  32. package/docs/adopted-deploys.md +14 -0
  33. package/docs/launchd-ports.md +109 -0
  34. package/docs/launchd.md +5 -0
  35. package/docs/release-binary-catalogs.md +32 -7
  36. package/docs/release-binary-openbao.md +151 -0
  37. package/docs/release-binary.md +13 -2
  38. package/package.json +1 -1
  39. package/src/launchd/index.ts +2 -0
  40. package/src/launchd/port-claims.test.ts +124 -0
  41. package/src/launchd/port-claims.ts +102 -0
  42. package/src/launchd/providers.test.ts +3 -0
  43. package/src/proxmox/api-token-adopt.test.ts +138 -0
  44. package/src/proxmox/api-token-form.ts +19 -2
  45. package/src/proxmox/api-token.ts +8 -3
  46. package/src/proxmox/generated/pve/cluster-sdn-fabrics.ts +23 -13
  47. package/src/proxmox/index.test.ts +5 -2
  48. package/src/proxmox/index.ts +9 -2
  49. package/src/proxmox/zfs-pool-adopt.test.ts +141 -0
  50. package/src/proxmox/zfs-pool-spec.ts +76 -0
  51. package/src/proxmox/zfs-pool-write.ts +63 -9
  52. package/src/proxmox/zfs-pool.ts +45 -95
  53. package/src/release/catalog.test.ts +71 -0
  54. package/src/release/catalog.ts +68 -5
  55. package/src/release/fixtures/openbao/checksums.txt +83 -0
  56. package/src/release/fixtures/openbao/checksums.txt.gpgsig +0 -0
  57. package/src/release/fixtures/openbao/darwin-assets.txt +12 -0
  58. package/src/release/fixtures/openbao/openbao-gpg-pub-20240618.asc +109 -0
  59. package/src/release/index.ts +3 -0
  60. package/src/release/openbao.test.ts +120 -0
  61. package/src/release/openbao.ts +86 -0
  62. package/src/release/providers.test.ts +1 -0
@@ -0,0 +1,151 @@
1
+ # Release binaries — the OpenBao walk-down
2
+
3
+ Status: active
4
+ Verified: 2026-09-23
5
+
6
+ `OPENBAO_RELEASES` (`src/release/openbao.ts`) is the second data set, and the
7
+ first whose checksum file never lists its own binary. Every command below was
8
+ run 2026-09-23 against `openbao/openbao` v2.6.2, from this worktree, network
9
+ reads only — nothing executed the vendor binary.
10
+
11
+ ## The release itself
12
+
13
+ ```
14
+ gh api repos/openbao/openbao/releases/tags/v2.6.2
15
+ ```
16
+
17
+ `published_at` 2026-08-18T16:21:00Z, `immutable: false`, 251 assets.
18
+ `openbao_2.6.2_darwin_arm64.tar.gz`: 76,833,570 bytes,
19
+ `digest: sha256:4e495376174accc0e014d31e9901f518a974f966850c839f626347eaac05fd52`.
20
+
21
+ ## checksums.txt
22
+
23
+ ```
24
+ curl -sL -o checksums.txt \
25
+ https://github.com/openbao/openbao/releases/download/v2.6.2/checksums.txt
26
+ shasum -a 256 checksums.txt
27
+ ```
28
+
29
+ 8,683 bytes, `sha256 3d4a19fdc54a86fd94ce59ea91316dbf4304180bb27ae818e78e85c7f2164645`
30
+ — equal to GitHub's own `digest` for the asset. 83 lines, every one matching
31
+ `^[0-9a-f]{64} [^\s/]+$` (`checksums.ts`'s parser), LF endings, a trailing
32
+ newline, no name repeated. Line 20 is the darwin_arm64 archive and equals the
33
+ release API's digest. **41 of the 83 lines are `.sbom.json`; the rest are
34
+ archives, `.deb` or `.rpm` — never `bao` itself**: `grep -n ' bao$'
35
+ checksums.txt` finds nothing. That is why the member digest is `computed`,
36
+ never `members` (checksums.ts's header explains the shape generally; this is
37
+ OpenBao's instance of it).
38
+
39
+ ## The signature over checksums.txt
40
+
41
+ ```
42
+ curl -sL -o checksums.txt.gpgsig \
43
+ https://github.com/openbao/openbao/releases/download/v2.6.2/checksums.txt.gpgsig
44
+ curl -sL -o openbao-gpg-pub-20240618.asc \
45
+ https://openbao.org/assets/openbao-gpg-pub-20240618.asc
46
+ gpg --dearmor -o openbao-pub.gpg openbao-gpg-pub-20240618.asc
47
+ gpgv --keyring ./openbao-pub.gpg checksums.txt.gpgsig checksums.txt
48
+ ```
49
+
50
+ ```
51
+ gpgv: Signature made Tue Aug 18 11:57:25 2026 EDT
52
+ gpgv: using RSA key E617DCD4065C2AFC0B2CF7A7BA8BC08C0F691F94
53
+ gpgv: Good signature from "OpenBao <openbao@lists.lfedge.org>"
54
+ ```
55
+
56
+ `gpg --show-keys --with-fingerprint` on the same file: primary
57
+ `66D1 5FDD 8728 7219 C8E1 5478 D200 CD70 2853 E6D0`, signing subkey
58
+ `E617 DCD4 065C 2AFC 0B2C F7A7 BA8B C08C 0F69 1F94` — both match OpenBao's own
59
+ `install.mdx` at this tag. `checksums.txt.gpgsig` is 566 bytes, sha256
60
+ `40f294d642a2b4e9adac766ca6b8b2633e15b3dbfdf63969dedbf46d94df53e8`; the key
61
+ file is 6,896 bytes, sha256
62
+ `1862a196422947124282e026ea4e09d6f7c5d383b628d90c17906936e6e5a8e0`.
63
+
64
+ ⚠️ **Trust on first use, across two channels one project runs**: the release
65
+ asset (GitHub) and the key (openbao.org) are both OpenBao's own
66
+ infrastructure. A verified signature says the two agree, not that a third
67
+ party attests to either. `cosign` is not installed on the mini (`which
68
+ cosign` — not found), so the Sigstore bundle alongside `checksums.txt` was
69
+ read, not verified: its certificate names
70
+ `https://github.com/openbao/openbao/.github/workflows/release.yml@refs/heads/release/2.6.x`,
71
+ issuer `https://token.actions.githubusercontent.com` — recorded in
72
+ `openbao.ts` as an **unverified** identity. "GPG or Sigstore" is met by the
73
+ GPG half.
74
+
75
+ Attestations, for completeness: `gh api
76
+ repos/openbao/openbao/attestations/sha256:4e49…` → 404. Positive control
77
+ (`gh api repos/cli/cli/attestations/sha256:<its macOS_arm64 digest>`) → 200,
78
+ 2 attestations, same day. A 404 here means "none published", not "the API is
79
+ down".
80
+
81
+ ## The archive: download, hash, unpack
82
+
83
+ ```
84
+ curl -sL -o openbao_2.6.2_darwin_arm64.tar.gz \
85
+ https://github.com/openbao/openbao/releases/download/v2.6.2/openbao_2.6.2_darwin_arm64.tar.gz
86
+ shasum -a 256 openbao_2.6.2_darwin_arm64.tar.gz
87
+ tar -tvzf openbao_2.6.2_darwin_arm64.tar.gz
88
+ ```
89
+
90
+ 76,833,570 bytes, sha256
91
+ `4e495376174accc0e014d31e9901f518a974f966850c839f626347eaac05fd52` — equal to
92
+ the pin. `tar -tvzf` (full stream, nothing saved beyond the archive itself):
93
+ four root-level regular files, `ustar`, no PAX header, no directory entry —
94
+
95
+ ```
96
+ -rw-r--r-- 0 runner runner 131858 CHANGELOG.md
97
+ -rw-r--r-- 0 runner runner 15958 LICENSE
98
+ -rw-r--r-- 0 runner runner 7136 README.md
99
+ -rwxr-xr-x 0 root root 193769394 bao
100
+ ```
101
+
102
+ The kit's own `extractMembers` (`archive.ts`) read the same stream without
103
+ refusing it and returned the same four names, in the same order — the
104
+ previous partial-range read (`release-binary-catalogs.md`'s queue note) is
105
+ superseded by this full-stream one, so **no tar-reader change is needed for
106
+ this vendor**.
107
+
108
+ ## The member digest — computed, not the vendor's
109
+
110
+ ```
111
+ tar -xzOf openbao_2.6.2_darwin_arm64.tar.gz bao | shasum -a 256
112
+ ```
113
+
114
+ `d476d17e81a35e6d70dd7e86a8ab2a3664313525118f1f1cfe0130e7a2b95f3a` — and the
115
+ kit's `extractMembers` over the same bytes produced the identical digest. Two
116
+ independent readers agree, which is what `computed` in `openbao.ts` records,
117
+ dated, next to a header explaining it is not a vendor fact.
118
+
119
+ ## The extracted binary, never run
120
+
121
+ ```
122
+ tar -xzf openbao_2.6.2_darwin_arm64.tar.gz bao
123
+ chmod -x bao # never made executable in this worktree
124
+ otool -L bao
125
+ codesign -dv --verbose=2 bao
126
+ xattr -l bao
127
+ ```
128
+
129
+ `otool -L`: `libSystem.B.dylib`, `libresolv.9.dylib`, CoreFoundation,
130
+ Security — all system frameworks, none from `/nix/store` — the same class as
131
+ Victoria's five. `codesign`: `flags=0x20002(adhoc,linker-signed)`, no
132
+ TeamIdentifier — a stock Go/macOS ad-hoc signature, same as the Victoria
133
+ binaries. `xattr -l`: `com.apple.provenance` only, no quarantine. Build
134
+ provenance (`goreleaser.other.yaml` at v2.6.2, read, not re-measured here):
135
+ `CGO_ENABLED=0`, tag `ui` — the same build class the darwin release ships for
136
+ every OpenBao platform.
137
+
138
+ Both scratch copies (`bao`, the `.tar.gz`) were deleted after these commands;
139
+ nothing was executed and nothing was written outside the scratch directory.
140
+
141
+ ## What this does NOT establish
142
+
143
+ - **Sigstore identity is read, not cryptographically checked** — `cosign` was
144
+ not run. Treat the workflow identity above as a claim, not a verification.
145
+ - **The agent never ran `bao`.** Whether it starts under launchd, and whether
146
+ its approle auto-auth / template / exec behaviour matches the Nix build
147
+ the vault server runs today, is unmeasured — expected to be unaffected
148
+ (nothing here suggests otherwise), but nobody has watched it happen.
149
+ - **No adoption or install-path measurement.** This PR adds only the catalog
150
+ entry, its tests and this record — no `ReleaseBinary` declaration, no
151
+ consuming stack change.
@@ -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.23.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',
@@ -0,0 +1,138 @@
1
+ /**
2
+ * `Proxmox.ApiToken` adopted, undeclared and drifted through Alchemy's own Plan and Apply, over a
3
+ * fake cluster — pinning the metadata-only shape decision 9 (2026-09-23) builds: adopt and manage
4
+ * `comment`/`expire`/`privsep`, never mint a token this resource cannot hand anybody the secret
5
+ * for (api-token.ts's header).
6
+ *
7
+ * ★ FOUR LIVE SHAPES: `metrics@pve!exporter` and `iac@pve!apply` are the no-comment / commented
8
+ * shapes named in api-token.ts's own header; `svc@pve!lease1` and `svc@pve!lease2` are
9
+ * PLACEHOLDERS (never an estate mint-user account — see the ⛔ on declaring under one in
10
+ * api-token.ts's header) exercising a real non-zero `expire` offered as BOTH a JSON number and
11
+ * a JSON string, which PVE does depending on path — `int()` (values.ts) must not care which.
12
+ */
13
+ import { describe, expect, test } from 'bun:test';
14
+ import * as Effect from 'effect/Effect';
15
+ import * as Layer from 'effect/Layer';
16
+ import { engineOver } from '../verify/fake-engine.ts';
17
+ import { type ApiTokenProps, ProxmoxApiToken, ProxmoxApiTokenProvider } from './api-token.ts';
18
+ import { shape } from './api-token-form.ts';
19
+ import { FAKE_TARGET, type PveCall, fakePve, withoutBao } from './fake-pve.ts';
20
+ import type {
21
+ AccessUsersUseridTokenTokenidPostParams,
22
+ AccessUsersUseridTokenTokenidPutParams,
23
+ } from './generated/pve.ts';
24
+
25
+ type Live = { comment?: string; expire: number | string; privsep: number };
26
+
27
+ /** `access/users/{userid}/token/{tokenid}`, mutable so the PUT test can watch it change. */
28
+ const tokens = new Map<string, Live>([
29
+ ['access/users/metrics@pve/token/exporter', { expire: 0, privsep: 0 }],
30
+ ['access/users/iac@pve/token/apply', { comment: 'automation', expire: 0, privsep: 0 }],
31
+ ['access/users/svc@pve/token/lease1', { expire: 1789327175, privsep: 0 }],
32
+ ['access/users/svc@pve/token/lease2', { comment: 'lease', expire: '1789327175', privsep: 0 }],
33
+ ]);
34
+
35
+ const cluster = () =>
36
+ fakePve((call: PveCall) => {
37
+ if (call.method === 'GET') return tokens.get(call.path);
38
+ if (call.method === 'PUT') {
39
+ const live = tokens.get(call.path);
40
+ if (live !== undefined) tokens.set(call.path, { ...live, ...call.form });
41
+ return call.form;
42
+ }
43
+ return undefined;
44
+ });
45
+
46
+ /** The declared props matching a live fixture exactly — `expire` always as a `number` prop. */
47
+ const propsFor = (
48
+ userid: string,
49
+ tokenid: string,
50
+ over: { comment?: string; privsep?: boolean },
51
+ ): ApiTokenProps => ({
52
+ expire: Number(tokens.get(`access/users/${userid}/token/${tokenid}`)?.expire ?? 0),
53
+ privsep: over.privsep ?? false,
54
+ target: FAKE_TARGET,
55
+ tokenid,
56
+ userid,
57
+ ...(over.comment === undefined ? {} : { comment: over.comment }),
58
+ });
59
+
60
+ const token = (userid: string, tokenid: string, over: { comment?: string; privsep?: boolean }) =>
61
+ ProxmoxApiToken(`${userid.replace('@', '-')}-${tokenid}`, propsFor(userid, tokenid, over));
62
+
63
+ const engineFor = (fake: ReturnType<typeof cluster>) =>
64
+ engineOver(ProxmoxApiTokenProvider().pipe(Layer.provideMerge(fake.layer)));
65
+
66
+ const declared = () =>
67
+ Effect.all([
68
+ token('metrics@pve', 'exporter', {}),
69
+ token('iac@pve', 'apply', { comment: 'automation' }),
70
+ token('svc@pve', 'lease1', {}),
71
+ token('svc@pve', 'lease2', { comment: 'lease' }),
72
+ ]);
73
+
74
+ describe('adopting a metadata-only ApiToken declaration', () => {
75
+ test('no comment, commented, and a real expire as both a number and a string: noop, adopted, no writes', async () => {
76
+ const fake = cluster();
77
+ await withoutBao(async () => {
78
+ const engine = engineFor(fake);
79
+ const report = await engine.verify(declared());
80
+ expect(report.rows).toHaveLength(4);
81
+ for (const row of report.rows) {
82
+ expect(row).toMatchObject({ changed: [], diff: 'noop', ok: true, planned: 'adopted' });
83
+ }
84
+ const planned = await engine.deploy(declared());
85
+ expect(new Set(Object.values(planned))).toEqual(new Set(['adopted']));
86
+ expect(Object.keys(planned)).toHaveLength(4);
87
+ });
88
+ expect(fake.writes()).toEqual([]);
89
+ });
90
+
91
+ test('undeclaring all four sends no DELETE (retain is the default)', async () => {
92
+ const fake = cluster();
93
+ await withoutBao(async () => {
94
+ const engine = engineFor(fake);
95
+ await engine.deploy(declared());
96
+ expect(await engine.deploy(Effect.void)).toEqual({});
97
+ });
98
+ expect(fake.writes()).toEqual([]);
99
+ });
100
+
101
+ test('an absent token: the deploy refuses with the mint-elsewhere message, and writes nothing', async () => {
102
+ const fake = cluster();
103
+ const gone = () =>
104
+ ProxmoxApiToken('gone', {
105
+ expire: 0,
106
+ privsep: false,
107
+ target: FAKE_TARGET,
108
+ tokenid: 'missing',
109
+ userid: 'nope@pve',
110
+ });
111
+ await withoutBao(async () => {
112
+ const engine = engineFor(fake);
113
+ await expect(engine.deploy(gone())).rejects.toBeDefined();
114
+ });
115
+ expect(fake.writes()).toEqual([]);
116
+ });
117
+
118
+ test('a privsep flip: verify shows changed, and the deploy sends exactly one PUT of comment/expire/privsep', async () => {
119
+ const fake = cluster();
120
+ const flipped = () => token('iac@pve', 'apply', { comment: 'automation', privsep: true });
121
+ await withoutBao(async () => {
122
+ const engine = engineFor(fake);
123
+ const report = await engine.verify(flipped());
124
+ expect(report.rows[0]).toMatchObject({ changed: ['privsep'], diff: 'update', ok: false });
125
+ expect(await engine.deploy(flipped())).toEqual({ 'iac-pve-apply': 'adopted' });
126
+ });
127
+ const put = fake.calls.find((call) => call.method === 'PUT');
128
+ expect(fake.writes()).toEqual(['PUT access/users/iac@pve/token/apply']);
129
+ expect(put?.pairs.map(([name]) => name)).toEqual(['comment', 'expire', 'privsep']);
130
+ });
131
+
132
+ test('the form keys typecheck against both generated PVE params, with no cast', () => {
133
+ const props = propsFor('iac@pve', 'apply', { comment: 'automation' });
134
+ const post: AccessUsersUseridTokenTokenidPostParams = shape(props);
135
+ const put: AccessUsersUseridTokenTokenidPutParams = shape(props);
136
+ expect(post).toEqual(put);
137
+ });
138
+ });
@@ -16,6 +16,10 @@ import type { ApiTokenAttributes, ApiTokenProps } from './api-token.ts';
16
16
  * that costs a forever-diff: the schema gives `expire` the default "same as user", and the code
17
17
  * never implements it. api-token.ts records what that means.
18
18
  */
19
+ import type {
20
+ AccessUsersUseridTokenTokenidPostParams,
21
+ AccessUsersUseridTokenTokenidPutParams,
22
+ } from './generated/pve.ts';
19
23
  import type { PveSpec } from './resource.ts';
20
24
  import { bool, int, text } from './values.ts';
21
25
 
@@ -56,10 +60,23 @@ export const fullTokenid = (props: ApiTokenProps) => `${props.userid}!${props.to
56
60
  * required prop precisely so that it is never undeclared, so there is nothing for it to do and
57
61
  * its `string | undefined` return would have to be asserted away to fit `Record<string,string>`.
58
62
  * An assertion here would be a claim about the prop that the type already makes properly.
63
+ *
64
+ * ★ TYPED AGAINST THE GENERATED PUT AND POST PARAMS, NOT `Record<string, string>`, since
65
+ * 2026-09-23 — the `notification-matcher-form.ts` pattern: `Pick` rather than the whole type,
66
+ * because neither `AccessUsersUseridTokenTokenidPutParams` nor …`PostParams` (generated/pve.ts)
67
+ * carries `userid`/`tokenid` (they are path segments — see the ⛔ above) and Put alone also
68
+ * carries `delete`/`regenerate`, which this family never sends. A schema drift on any of the
69
+ * three fields this function DOES send fails `tsc` here rather than surfacing as a 400 on a live
70
+ * cluster; api-token.test.ts pins the assignment both ways (Put and Post) without a cast.
71
+ * `expire` is written as a template literal, not `String(...)`, because the generated type is
72
+ * `` `${number}` ``, not `string` — `String(n)` widens to plain `string` and would not typecheck.
59
73
  */
60
- export const shape = (props: ApiTokenProps): Record<string, string> => ({
74
+ export const shape = (
75
+ props: ApiTokenProps,
76
+ ): Pick<AccessUsersUseridTokenTokenidPutParams, 'comment' | 'expire' | 'privsep'> &
77
+ Pick<AccessUsersUseridTokenTokenidPostParams, 'comment' | 'expire' | 'privsep'> => ({
61
78
  comment: props.comment ?? '',
62
- expire: String(props.expire),
79
+ expire: `${props.expire}`,
63
80
  privsep: props.privsep ? '1' : '0',
64
81
  });
65
82
 
@@ -20,9 +20,14 @@
20
20
  * whole of a token's policy, and declaring them is real work: it is how `expire` stops being
21
21
  * whatever somebody typed in 2024. Mint NEW tokens where the secret can be caught — OpenBao's
22
22
  * `proxmox-c1` mount, or a human at `pveum user token add`.
23
- * ⚠️ THE CREATE PATH IS LEFT REACHABLE RATHER THAN STUBBED. A create that silently did nothing
24
- * would be the same lie as the stubbed delete resource.ts's ★ refuses; the honest arrangement
25
- * is a create that works and a header that says what it produces.
23
+ * ⛔ ★ CORRECTED 2026-09-23 — THIS PARAGRAPH PREVIOUSLY SAID "THE CREATE PATH IS LEFT REACHABLE
24
+ * RATHER THAN STUBBED… the honest arrangement is a create that works". THAT CONTRADICTED THE
25
+ * `reconcile` BELOW, WHICH REFUSES: `handlers.reconcile` calls `Effect.die` by name when `read`
26
+ * answers absent, before any POST — metadata-only per decision 9 (2026-09-23), exported on the
27
+ * barrel as an adopt-and-manage-only Resource. A create that silently did nothing WOULD be the
28
+ * lie resource.ts's ★ describes for a stubbed delete; refusing loudly, by name, with the two
29
+ * ways to get a usable token, is the honest arrangement instead — see the ★ further down and
30
+ * api-token-adopt.test.ts's absent-token row, which pins the refusal and zero writes.
26
31
  *
27
32
  * ⛔ THERE IS NO `regenerate` PROP, AND IT IS NOT AN OVERSIGHT. PVE's own description: "All users
28
33
  * of the previous secret will lose access after this operation." A prop for it would revoke a