@homeflare/alchemy 0.19.1 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -0
- package/dist/caddy/access-forward-auth.d.ts +56 -0
- package/dist/caddy/access-forward-auth.d.ts.map +1 -0
- package/dist/caddy/index.d.ts +6 -0
- package/dist/caddy/index.d.ts.map +1 -1
- package/dist/caddy/index.js +169 -75
- package/dist/caddy/index.js.map +10 -7
- package/dist/cloudflare/mcp-server-api.d.ts +135 -0
- package/dist/cloudflare/mcp-server-api.d.ts.map +1 -0
- package/dist/cloudflare/mcp-server-form.d.ts +116 -0
- package/dist/cloudflare/mcp-server-form.d.ts.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/launchd/file-converge.d.ts +70 -0
- package/dist/launchd/file-converge.d.ts.map +1 -0
- package/dist/launchd/file-identity.d.ts +32 -0
- package/dist/launchd/file-identity.d.ts.map +1 -0
- package/dist/launchd/host-file-form.d.ts +2 -0
- package/dist/launchd/host-file-form.d.ts.map +1 -1
- package/dist/launchd/host-file-lifecycle.d.ts +11 -24
- package/dist/launchd/host-file-lifecycle.d.ts.map +1 -1
- package/dist/launchd/host-file.d.ts.map +1 -1
- package/dist/launchd/index.js +173 -120
- package/dist/launchd/index.js.map +12 -10
- package/dist/launchd/local-runner.d.ts.map +1 -1
- package/dist/launchd/runner.d.ts +17 -2
- package/dist/launchd/runner.d.ts.map +1 -1
- package/dist/launchd/sudo-runner.d.ts +11 -0
- package/dist/launchd/sudo-runner.d.ts.map +1 -1
- package/dist/linux/index.js +37 -11
- package/dist/linux/index.js.map +10 -10
- package/dist/linux/region.d.ts +11 -0
- package/dist/linux/region.d.ts.map +1 -1
- package/dist/linux/remote-file-lifecycle.d.ts.map +1 -1
- package/dist/linux/remote-file-plan.d.ts +22 -0
- package/dist/linux/remote-file-plan.d.ts.map +1 -1
- package/dist/linux/remote-file.d.ts +4 -1
- package/dist/linux/remote-file.d.ts.map +1 -1
- package/dist/linux/sudo-listing.d.ts +47 -0
- package/dist/linux/sudo-listing.d.ts.map +1 -0
- package/dist/linux/unit-handlers.d.ts.map +1 -1
- package/dist/linux/unit-preflight.d.ts +10 -1
- package/dist/linux/unit-preflight.d.ts.map +1 -1
- package/dist/proxmox/credential-literals.d.ts +1 -1
- package/dist/proxmox/credential-literals.d.ts.map +1 -1
- package/dist/proxmox/generated/pbs/nodes-node.d.ts +1 -1
- package/dist/proxmox/generated/pbs/nodes-node.d.ts.map +1 -1
- package/dist/proxmox/generated/pbs.d.ts +7 -0
- package/dist/proxmox/generated/pbs.d.ts.map +1 -1
- package/dist/proxmox/generated/pve.d.ts +7 -0
- package/dist/proxmox/generated/pve.d.ts.map +1 -1
- package/dist/proxmox/index.d.ts +16 -4
- package/dist/proxmox/index.d.ts.map +1 -1
- package/dist/proxmox/index.js +5 -2
- package/dist/proxmox/index.js.map +9 -9
- package/dist/proxmox/pbs-notification-target-form.d.ts +1 -1
- package/dist/proxmox/pbs-notification-target-form.d.ts.map +1 -1
- package/dist/proxmox/pbs-notification-target.d.ts +1 -1
- package/dist/proxmox/pbs-notification-target.d.ts.map +1 -1
- package/dist/release/archive.d.ts +11 -0
- package/dist/release/archive.d.ts.map +1 -0
- package/dist/release/binary-claim.d.ts +53 -0
- package/dist/release/binary-claim.d.ts.map +1 -0
- package/dist/release/binary-diff.d.ts +38 -0
- package/dist/release/binary-diff.d.ts.map +1 -0
- package/dist/release/binary-form.d.ts +118 -0
- package/dist/release/binary-form.d.ts.map +1 -0
- package/dist/release/binary-lifecycle.d.ts +55 -0
- package/dist/release/binary-lifecycle.d.ts.map +1 -0
- package/dist/release/binary-read.d.ts +11 -0
- package/dist/release/binary-read.d.ts.map +1 -0
- package/dist/release/binary.d.ts +68 -0
- package/dist/release/binary.d.ts.map +1 -0
- package/dist/release/catalog.d.ts +76 -0
- package/dist/release/catalog.d.ts.map +1 -0
- package/dist/release/checksums.d.ts +22 -0
- package/dist/release/checksums.d.ts.map +1 -0
- package/dist/release/declared-pins.d.ts +4 -0
- package/dist/release/declared-pins.d.ts.map +1 -0
- package/dist/release/download.d.ts +44 -0
- package/dist/release/download.d.ts.map +1 -0
- package/dist/release/fake-deploy.d.ts +48 -0
- package/dist/release/fake-deploy.d.ts.map +1 -0
- package/dist/release/fake-engine.d.ts +8 -0
- package/dist/release/fake-engine.d.ts.map +1 -0
- package/dist/release/fake-install.d.ts +39 -0
- package/dist/release/fake-install.d.ts.map +1 -0
- package/dist/release/fake-provider.d.ts +58 -0
- package/dist/release/fake-provider.d.ts.map +1 -0
- package/dist/release/fake-release.d.ts +95 -0
- package/dist/release/fake-release.d.ts.map +1 -0
- package/dist/release/index.d.ts +24 -0
- package/dist/release/index.d.ts.map +1 -0
- package/dist/release/index.js +1365 -0
- package/dist/release/index.js.map +35 -0
- package/dist/release/providers.d.ts +19 -0
- package/dist/release/providers.d.ts.map +1 -0
- package/dist/release/refused.d.ts +39 -0
- package/dist/release/refused.d.ts.map +1 -0
- package/dist/release/tar.d.ts +14 -0
- package/dist/release/tar.d.ts.map +1 -0
- package/dist/release/victoria.d.ts +36 -0
- package/dist/release/victoria.d.ts.map +1 -0
- package/dist/{proxmox → secrets}/write-only.d.ts.map +1 -1
- package/docs/adopted-deploys.md +13 -0
- package/docs/linux-host.md +5 -1
- package/docs/provider-standard.md +138 -0
- package/docs/release-binary-catalogs.md +113 -0
- package/docs/release-binary-measured.md +163 -0
- package/docs/release-binary-refusals.md +57 -0
- package/docs/release-binary-upstream.md +163 -0
- package/docs/release-binary.md +154 -0
- package/docs/upstream-conformance.md +164 -0
- package/package.json +5 -1
- package/src/caddy/access-forward-auth.test.ts +63 -0
- package/src/caddy/access-forward-auth.ts +120 -0
- package/src/caddy/index.ts +10 -0
- package/src/caddy/providers.test.ts +4 -0
- package/src/cloudflare/mcp-server-api.ts +294 -0
- package/src/cloudflare/mcp-server-form.ts +219 -0
- package/src/index.ts +1 -0
- package/src/launchd/file-converge.ts +233 -0
- package/src/launchd/file-identity.ts +69 -0
- package/src/launchd/host-file-alias.test.ts +65 -0
- package/src/launchd/host-file-form.ts +2 -1
- package/src/launchd/host-file-lifecycle.ts +25 -144
- package/src/launchd/host-file.ts +8 -2
- package/src/launchd/local-runner.test.ts +12 -1
- package/src/launchd/local-runner.ts +2 -0
- package/src/launchd/runner.ts +17 -2
- package/src/launchd/sudo-runner.ts +11 -0
- package/src/linux/region.ts +15 -0
- package/src/linux/remote-file-lifecycle.test.ts +0 -88
- package/src/linux/remote-file-lifecycle.ts +25 -7
- package/src/linux/remote-file-plan.ts +37 -1
- package/src/linux/remote-file-region.test.ts +181 -0
- package/src/linux/remote-file.ts +4 -1
- package/src/linux/sudo-listing.ts +122 -0
- package/src/linux/unit-handlers.ts +12 -5
- package/src/linux/unit-preflight.ts +20 -4
- package/src/linux/unit-rename.test.ts +76 -0
- package/src/proxmox/ceph-adopt.test.ts +210 -0
- package/src/proxmox/credential-literals.ts +1 -1
- package/src/proxmox/generated/pbs/nodes-node.ts +1 -1
- package/src/proxmox/generated/pbs.ts +7 -0
- package/src/proxmox/generated/pve.ts +7 -0
- package/src/proxmox/index.test.ts +7 -4
- package/src/proxmox/index.ts +16 -4
- package/src/proxmox/pbs-notification-target-form.ts +1 -1
- package/src/proxmox/pbs-notification-target-lifecycle.ts +1 -1
- package/src/proxmox/pbs-notification-target-wire.ts +1 -1
- package/src/proxmox/pbs-notification-target.ts +1 -1
- package/src/release/adopt-parity.test.ts +106 -0
- package/src/release/apply-pins.test.ts +128 -0
- package/src/release/archive.ts +94 -0
- package/src/release/binary-alias.test.ts +109 -0
- package/src/release/binary-claim.ts +80 -0
- package/src/release/binary-claims.test.ts +47 -0
- package/src/release/binary-diff.ts +73 -0
- package/src/release/binary-form.ts +229 -0
- package/src/release/binary-lifecycle.test.ts +90 -0
- package/src/release/binary-lifecycle.ts +202 -0
- package/src/release/binary-read.ts +55 -0
- package/src/release/binary-refusals.test.ts +192 -0
- package/src/release/binary-sudo.test.ts +93 -0
- package/src/release/binary.ts +181 -0
- package/src/release/catalog.test.ts +96 -0
- package/src/release/catalog.ts +175 -0
- package/src/release/checksums.test.ts +35 -0
- package/src/release/checksums.ts +43 -0
- package/src/release/declared-pins.ts +39 -0
- package/src/release/download.test.ts +138 -0
- package/src/release/download.ts +110 -0
- package/src/release/error-channel.test.ts +69 -0
- package/src/release/fake-deploy.ts +87 -0
- package/src/release/fake-engine.ts +50 -0
- package/src/release/fake-install.ts +59 -0
- package/src/release/fake-provider.ts +68 -0
- package/src/release/fake-release.ts +185 -0
- package/src/release/fixtures/victoria/darwin-arm64-assets.txt +22 -0
- package/src/release/fixtures/victoria/victoria-logs-darwin-arm64-v1.52.0_checksums.txt +2 -0
- package/src/release/fixtures/victoria/victoria-metrics-darwin-arm64-v1.151.0_checksums.txt +2 -0
- package/src/release/fixtures/victoria/victoria-traces-darwin-arm64-v0.10.0_checksums.txt +2 -0
- package/src/release/fixtures/victoria/vmutils-darwin-arm64-v1.151.0_checksums.txt +8 -0
- package/src/release/index.ts +41 -0
- package/src/release/plan.test.ts +189 -0
- package/src/release/provider-refusals.test.ts +48 -0
- package/src/release/providers.test.ts +174 -0
- package/src/release/providers.ts +24 -0
- package/src/release/recovery-read.test.ts +44 -0
- package/src/release/refused.ts +40 -0
- package/src/release/tar.test.ts +142 -0
- package/src/release/tar.ts +195 -0
- package/src/release/victoria.test.ts +108 -0
- package/src/release/victoria.ts +154 -0
- package/src/{proxmox → secrets}/write-only.ts +6 -0
- /package/dist/{proxmox → secrets}/write-only.d.ts +0 -0
- /package/src/{proxmox → secrets}/write-only.test.ts +0 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# Release binaries — what upstream would need
|
|
2
|
+
|
|
3
|
+
Status: active
|
|
4
|
+
Verified: 2026-09-22 (against `alchemy-run/alchemy` at `v2.0.0-beta.79`, read-only:
|
|
5
|
+
AGENTS.md and `scripts/generate-api-reference.ts`, plus the installed
|
|
6
|
+
`src/Command/Exec.ts`, `src/GitHub/Release.ts`, `src/GitHub/BaseUrl.ts`,
|
|
7
|
+
`src/Util/AtomicFile.ts`, `src/Hetzner/Ssh.ts` and `src/Hetzner/Providers.ts`)
|
|
8
|
+
|
|
9
|
+
`Release.Binary` is built so it **could** be contributed to Alchemy. This page
|
|
10
|
+
is the honest gap list: what already fits, and what a pull request to
|
|
11
|
+
`alchemy-run/alchemy` would have to change first. Nothing here is a promise
|
|
12
|
+
that upstream wants it.
|
|
13
|
+
|
|
14
|
+
## Already shaped for it
|
|
15
|
+
|
|
16
|
+
- **Generic before its first release.** It began as `Victoria.Binary` with the
|
|
17
|
+
catalog inside the resource. It was generalised before any state row carried
|
|
18
|
+
that type name, because a rename after release would be a state migration.
|
|
19
|
+
The pinned archive (repo, tag, exact asset name, size, archive digest,
|
|
20
|
+
member, member digest, installed name) is props. Vendor data sets sit beside
|
|
21
|
+
it as plain modules. Alchemy could ship the resource without shipping anyone's
|
|
22
|
+
pins.
|
|
23
|
+
- **Named the upstream way.** The type is `Release.Binary` (`Namespace.Kind`).
|
|
24
|
+
Under upstream's layout it would be `src/Release/Binary.ts`, imported as
|
|
25
|
+
`import * as Release from "alchemy/Release"`, used as `Release.Binary`. The
|
|
26
|
+
house exports the prefixed `ReleaseBinary`, as it does `HostFile` and
|
|
27
|
+
`LaunchdJob`. Upstream's `GitHub.Release` already means _publishing_ a
|
|
28
|
+
release, so the GitHub namespace would be the confusing home.
|
|
29
|
+
- **The lifecycle is beta.79's**: `read`, `diff`, `reconcile`, `delete`,
|
|
30
|
+
`list` returning `[]`. No `create`/`update`. `reconcile` is one flow —
|
|
31
|
+
observe the path, ensure the bytes, sync mode and owner, read back — not a
|
|
32
|
+
create body and an update body. ⚠️ It does branch on `output` and `olds` for
|
|
33
|
+
**ownership**: only a create may take over a file under `--adopt`
|
|
34
|
+
(`file-converge.ts`), and the in-place guard compares `olds`' pins (gap 3).
|
|
35
|
+
- **Props are plain types**, never `Input<T>`, and every prop has JSDoc, with
|
|
36
|
+
`@default` where one applies. ⚠️ Not every attribute: `path`, `mode`, `uid`,
|
|
37
|
+
`gid` and `size` come from `HostFileAttributes` with no JSDoc, so the
|
|
38
|
+
generator would print them bare.
|
|
39
|
+
- **The resource JSDoc is the generator's shape**: `### Installing a binary`,
|
|
40
|
+
`**Example:**` with fenced TypeScript, and `@resource` last. (An untitled
|
|
41
|
+
example is titled "Example" by the generator.) ⚠️ The example needs
|
|
42
|
+
`HostDirectory`, `LaunchdJob` and `VICTORIA_RELEASES`, which upstream lacks.
|
|
43
|
+
- **Errors are tagged** (`Data.TaggedError`: `BinaryRefused`, `DownloadFailed`,
|
|
44
|
+
`ChecksumMismatch`, `ArchiveRefused`), and nothing casts on `_tag`.
|
|
45
|
+
⚠️ **They are not typed end to end, so no caller can `catchTag` one**
|
|
46
|
+
(measured, `error-channel.test.ts`). `catalogBinary()` throws, which in a
|
|
47
|
+
stack program is a defect that `catchTag` never sees. The provider's `lift()`
|
|
48
|
+
keeps the instance at runtime but types the channel as `Error` (gap 10).
|
|
49
|
+
- **HTTP is Effect's `HttpClient`**, and `reconcile` reports progress through
|
|
50
|
+
`session.note`. A provider layer that brings `FetchHttpClient.layer` itself
|
|
51
|
+
has upstream precedent (`Hetzner/Providers.ts`).
|
|
52
|
+
- **The props carry nothing estate-specific.** Paths, owners and modes are the
|
|
53
|
+
stack's. ⚠️ The wiring does carry the estate's name: the host service is
|
|
54
|
+
keyed `homeflare/launchd/HostRunner`, and the ownership notes are
|
|
55
|
+
`Artifacts` keys `homeflare/ownership/*` (gaps 1 and 2).
|
|
56
|
+
|
|
57
|
+
## What would have to change
|
|
58
|
+
|
|
59
|
+
1. **The host seam.** Every host touch goes through the kit's promise-based
|
|
60
|
+
`HostRunner`, a Context service under `launchd/`. Upstream would expect an
|
|
61
|
+
Effect service with a neutral key. ⚠️ `Util/AtomicFile.ts` is not that
|
|
62
|
+
service as it stands: it writes a **string** (`writeFileString`) and a mode,
|
|
63
|
+
never bytes, a uid or a gid. `FileSystem` reaches only the deploying
|
|
64
|
+
machine. Upstream's one remote-host seam is `Hetzner.Ssh` (Effect `exec` and
|
|
65
|
+
`scp`). A contribution needs a host service with a local implementation over
|
|
66
|
+
`FileSystem` and a remote one in that shape. The house seam is one file wide
|
|
67
|
+
on purpose, so the swap is local.
|
|
68
|
+
2. **Ownership semantics.** The house refuses to adopt without `--adopt`:
|
|
69
|
+
`read` returns `Unowned` even for a byte-identical file, and `reconcile`
|
|
70
|
+
resolves adoption at apply through `ownership/adopt.ts`. That reads
|
|
71
|
+
`Stack.resources[fqn].Adopt`, which upstream marks `@internal`, and state
|
|
72
|
+
rows. Upstream's default for a resource without ownership metadata is
|
|
73
|
+
**plain attributes, which means silent adoption**. A contribution would drop
|
|
74
|
+
`adoptsAtApply` / `noteUnfinished`, ask the engine to hand `adopt` to
|
|
75
|
+
`reconcile`, and settle whether a file on disk is ever adopted silently.
|
|
76
|
+
Worth keeping in any form: a file whose digest is not the pin is never
|
|
77
|
+
adopted, because adopting it is overwriting it (`binary-claim.ts`).
|
|
78
|
+
⚠️ `declared-pins.ts` reads the same `@internal` record (`.Props`) to refuse
|
|
79
|
+
a pin that was an Output on a first deploy. Upstream applies `Input<T>` to
|
|
80
|
+
every prop automatically, so "this prop must be a literal" is an engine
|
|
81
|
+
feature to propose (the declared props handed to `reconcile`, or a
|
|
82
|
+
literal-only marker), not something a provider can reach for.
|
|
83
|
+
3. **`diff` does live I/O and returns `noop`.** Upstream's guidance is that a
|
|
84
|
+
diff should almost never return `noop`. Live I/O has precedent, though:
|
|
85
|
+
`Command.Exec`'s diff hashes a directory and answers `noop` on a match, the
|
|
86
|
+
same shape as this one. Two rules are worth keeping in any form: a new pin
|
|
87
|
+
or path → `replace`, even while `directory` is an Output; and a new pin at
|
|
88
|
+
the same path is refused. The apply-time half of that refusal reads `olds`,
|
|
89
|
+
which upstream's reconciler doctrine calls "at most a hint". Restated as
|
|
90
|
+
observation it reads: the recorded path's live file is not these bytes.
|
|
91
|
+
4. **Layout and exports.** Upstream co-locates contract and provider in
|
|
92
|
+
`src/{Cloud}/{Service}/{Resource}.ts`, re-exports with `export *`, and
|
|
93
|
+
registers providers in a `Providers.ts` collection that `alchemy unsafe nuke`
|
|
94
|
+
enumerates. The house splits files under a 250-line cap, curates its barrel,
|
|
95
|
+
and exports `releaseProviders(runner)` with no collection.
|
|
96
|
+
5. **Where the data sets live.** Upstream ships no estate's pins. The resource,
|
|
97
|
+
`catalogBinary()` and the tar reader would go upstream; `victoria.ts` and its
|
|
98
|
+
fixtures would stay in the kit, or become a documented example.
|
|
99
|
+
6. **Tests.** The house tests run on fakes (`bun:test`, no network, no host),
|
|
100
|
+
plus Alchemy's real Plan and Apply over in-memory state (`plan.test.ts`).
|
|
101
|
+
Upstream tests run `pnpm test` against the real target, start and end with
|
|
102
|
+
`stack.destroy()`, and verify out of band. For a host resource the honest
|
|
103
|
+
equivalent is a real temporary directory and a real download. That was done
|
|
104
|
+
once, by hand, before the generalisation (release-binary-measured.md), and
|
|
105
|
+
CI here deliberately never does it.
|
|
106
|
+
7. **Toolchain and style.** pnpm, the alchemy-test runner, `tsc -b`, double
|
|
107
|
+
quotes; generated docs via `pnpm docs:gen`, never hand-edited.
|
|
108
|
+
8. **The pull request itself.** A conventional-commit title; no `#` or `##`
|
|
109
|
+
headings in the body (the smallest is `###`), the summary first with no
|
|
110
|
+
heading, no test plan, and code over prose.
|
|
111
|
+
9. **The directory must already exist.** `reconcile` refuses a missing one, and
|
|
112
|
+
the kit's `HostDirectory` (linux subpath) is what creates it. Upstream has no
|
|
113
|
+
such resource, so a contribution brings one, or a prop that creates the
|
|
114
|
+
directory with a declared mode and owner.
|
|
115
|
+
10. **A typed error channel.** `catalogBinary()` would return an `Effect` that
|
|
116
|
+
fails with `BinaryRefused`, and the provider would fail with the tag union
|
|
117
|
+
instead of `lift()`'s `Error`. `error-channel.test.ts` fails the moment
|
|
118
|
+
either changes, so this page moves with it.
|
|
119
|
+
11. **The tar reader accepts one measured layout.** Measured 2026-09-22 against
|
|
120
|
+
`extractMembers`: macOS's default `tar czf` (bsdtar 3.5.3) writes a PAX `x`
|
|
121
|
+
header before every entry (a sub-second `mtime` and the
|
|
122
|
+
`com.apple.provenance` xattr), so even a one-file archive is refused whole.
|
|
123
|
+
`--no-mac-metadata` still writes them; `--format ustar` passes. A wrapping
|
|
124
|
+
directory entry is refused whole. Go's `archive/tar` writing root-level
|
|
125
|
+
files passes. The house accepts this on purpose: the Victoria archives are
|
|
126
|
+
flat, and homeflare-builds writes plain ustar. ⚠️ REASONED NOT MEASURED:
|
|
127
|
+
the Prometheus family's archives (`alertmanager-0.34.1.darwin-arm64.tar.gz`)
|
|
128
|
+
wrap their files in a directory, so the queue in
|
|
129
|
+
release-binary-catalogs.md needs a reader change, not only a data set.
|
|
130
|
+
Generic replacement: skip directory entries, and links that are not the
|
|
131
|
+
declared member, because nothing is ever written from an entry name; read
|
|
132
|
+
PAX records and refuse only a `path`, `linkpath` or `size` record. The
|
|
133
|
+
member digest still guards the bytes.
|
|
134
|
+
12. **One source, one tag alphabet, other prop names.** `releaseUrl` fixes
|
|
135
|
+
`https://github.com`. Upstream's GitHub family also reaches GitHub
|
|
136
|
+
Enterprise (`normalizeGitHubBaseUrl`, `GitHub/BaseUrl.ts`). A tag must be
|
|
137
|
+
one `[A-Za-z0-9._-]` segment, so kustomize's `kustomize/v5.8.1` (measured:
|
|
138
|
+
GitHub's own download URL carries the raw `/`) and a changesets-style
|
|
139
|
+
`pkg@1.2.3` are refused at plan. Generic: validate git's ref-name rules,
|
|
140
|
+
segment by segment. Upstream's GitHub props split `owner` and `repository`
|
|
141
|
+
and call a tag `tagName`; this takes `repo: "owner/name"` and `tag`.
|
|
142
|
+
13. **No platform check.** `platform` is a catalog key, not a prop, and nothing
|
|
143
|
+
compares the archive's platform with the host. On a Linux runner, a stack
|
|
144
|
+
that asks for `darwin-arm64` gets a Mach-O written, read back and reported
|
|
145
|
+
installed; its unit then fails with "exec format error". Generic: a
|
|
146
|
+
`platform` prop, checked against the host (`uname -sm` through the host
|
|
147
|
+
service) or against the member's executable header.
|
|
148
|
+
|
|
149
|
+
## What should NOT change on the way
|
|
150
|
+
|
|
151
|
+
- ⛔ **Pins in code, never a checksum file fetched at apply.** Mutable releases
|
|
152
|
+
make a fetched checksum file a claim by the same party whose bytes it vouches
|
|
153
|
+
for. A pin that is an Output is refused for the same reason.
|
|
154
|
+
- ⛔ **Two layers.** Verify the archive before unpacking and the member before
|
|
155
|
+
writing. The member check is what still holds if a reader is ever wrong about
|
|
156
|
+
a boundary.
|
|
157
|
+
- ⛔ **Exact names.** An asset and a member are matched whole, never by a
|
|
158
|
+
prefix, a glob or "contains darwin-arm64", because the enterprise and cluster
|
|
159
|
+
siblings share every prefix.
|
|
160
|
+
- ⛔ **Refuse the whole archive** for any unsafe entry, extract only what was
|
|
161
|
+
declared, and stage nothing on disk before the one atomic write. ⚠️ Gap 11 is
|
|
162
|
+
where upstream is likeliest to push back: which entries count as unsafe when
|
|
163
|
+
nothing is written from an entry name.
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# Release binaries — `@homeflare/alchemy/release`
|
|
2
|
+
|
|
3
|
+
Status: active
|
|
4
|
+
Verified: 2026-09-22
|
|
5
|
+
|
|
6
|
+
`Release.Binary` (`ReleaseBinary`) installs one binary out of a pinned release
|
|
7
|
+
archive, verified twice: the archive against its pinned SHA-256 before anything
|
|
8
|
+
is unpacked, then the binary against its own before it is written. The pinned
|
|
9
|
+
archive is **props**. Each vendor's pinned versions are **data** kept beside
|
|
10
|
+
the resource, and `VICTORIA_RELEASES` is the first data set. It exists to
|
|
11
|
+
replace the Nix-built Victoria binaries on the Mac host: all five Nix builds
|
|
12
|
+
link a `/nix/store` `libresolv`, and the vendor builds link none.
|
|
13
|
+
|
|
14
|
+
⛔ **It installs; it never starts.** No `launchctl`, no `systemctl`, no restart.
|
|
15
|
+
The daemon is the stack's own `LaunchdJob` (or systemd unit), and it puts the
|
|
16
|
+
binary's `path` in its argv.
|
|
17
|
+
|
|
18
|
+
## Declaring one
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
import { HostDirectory, linuxProviders } from '@homeflare/alchemy/linux';
|
|
22
|
+
import { LaunchdJob, launchdProviders, localRunner } from '@homeflare/alchemy/launchd';
|
|
23
|
+
import {
|
|
24
|
+
ReleaseBinary, VICTORIA_RELEASES, catalogBinary, catalogDirectory, releaseProviders,
|
|
25
|
+
} from '@homeflare/alchemy/release';
|
|
26
|
+
|
|
27
|
+
const runner = localRunner();
|
|
28
|
+
// providers: Layer.mergeAll(launchdProviders(runner), linuxProviders(runner), releaseProviders(runner))
|
|
29
|
+
|
|
30
|
+
const vmalertOf = { package: 'vmutils', version: '1.151.0', platform: 'darwin-arm64', binary: 'vmalert' };
|
|
31
|
+
const dir = yield* HostDirectory('vmutils-1.151.0', {
|
|
32
|
+
path: catalogDirectory('/opt/example/bin', vmalertOf), // …/vmutils-1.151.0
|
|
33
|
+
mode: 0o755, owner: 0, group: 0,
|
|
34
|
+
});
|
|
35
|
+
const vmalert = yield* ReleaseBinary('vmalert', {
|
|
36
|
+
...catalogBinary(VICTORIA_RELEASES, vmalertOf), // archive, member, sha256, name
|
|
37
|
+
directory: dir.path, // ★ an Output: the engine creates the directory first
|
|
38
|
+
owner: 0, group: 0, mode: 0o555,
|
|
39
|
+
});
|
|
40
|
+
yield* LaunchdJob('vmalert', { programArguments: [vmalert.path, '--httpListenAddr=127.0.0.1:8880'], … });
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
- **`catalogBinary()` runs in your stack program.** A version, package,
|
|
44
|
+
platform or binary the data set does not pin throws `BinaryRefused` there,
|
|
45
|
+
so `alchemy plan` fails before any resource is asked anything
|
|
46
|
+
(`plan.test.ts`: zero requests, zero host calls).
|
|
47
|
+
- **The directory is yours, one per version.** It must exist.
|
|
48
|
+
`catalogDirectory(root, …)` names it `<root>/<package>-<version>`. A version
|
|
49
|
+
bump is then a new path. Measured through the engine (`plan.test.ts`):
|
|
50
|
+
mkdir new, download, write new, remove old, rmdir old, in that order.
|
|
51
|
+
- ⛔ **A new pin at the same path is refused**, at plan when the path is
|
|
52
|
+
resolved and at apply otherwise, rather than overwriting a binary under the
|
|
53
|
+
daemon that runs it. 🔴 While the directory is still an Output, a re-pin of
|
|
54
|
+
the same bytes is planned as an `update`, never a `replace`: measured through
|
|
55
|
+
the engine, a `replace` onto an unmoved path took the identical file as the
|
|
56
|
+
new generation's, then deleted it with the old one, and reported success.
|
|
57
|
+
- **The job references `vmalert.path`**, not a string it built. That Output
|
|
58
|
+
orders the job after the binary. A version bump moves the path, so the plist
|
|
59
|
+
changes and the job restarts on the new binary before the old one is deleted.
|
|
60
|
+
- **Declare each path once.** vmalert-logs is vmalert with other flags: one
|
|
61
|
+
`ReleaseBinary`, referenced by both jobs.
|
|
62
|
+
- **One logical id per binary, one per versioned directory.** `plan.test.ts`
|
|
63
|
+
declares `ReleaseBinary('vmalert')` and `HostDirectory('vmutils-<version>')`:
|
|
64
|
+
a bump plans the binary as a `replace`, the new directory as a `create` and
|
|
65
|
+
the old one as a `delete`, and the engine removes the old binary before its
|
|
66
|
+
directory.
|
|
67
|
+
- **Nothing estate-specific is in the kit.** Root, owner, group and mode are
|
|
68
|
+
your props. The data sets hold vendor facts only.
|
|
69
|
+
- **One runner for all of it.** Pass the same `HostRunner` to
|
|
70
|
+
`releaseProviders()` as to the jobs. There is no default: a binary goes
|
|
71
|
+
wherever the runner reaches. It provides its own `FetchHttpClient.layer`.
|
|
72
|
+
- **Your own pins work too.** The props are plain data, so a stack may write
|
|
73
|
+
them itself. ⛔ They must be plain values in the stack program: a pin wired
|
|
74
|
+
from another resource's Output (a checksum file read at apply) is refused,
|
|
75
|
+
on a first deploy too (`apply-pins.test.ts`). ⚠️ A stack program that
|
|
76
|
+
fetches the file itself and passes plain strings cannot be told apart from
|
|
77
|
+
a reviewed pin; `catalogBinary()` over a reviewed data set is the path
|
|
78
|
+
that keeps them reviewed.
|
|
79
|
+
|
|
80
|
+
## What reconcile does, in order
|
|
81
|
+
|
|
82
|
+
1. **Validate every prop**, before any host or network call. Every pin must be a
|
|
83
|
+
plain, well-formed value, as the stack program DECLARED it: by now an Output
|
|
84
|
+
has resolved to a plausible string (`declared-pins.ts`).
|
|
85
|
+
2. **Check the directory exists**, is not a symlink, and is not writable by
|
|
86
|
+
group or other (they could rename a file over the binary), before any download.
|
|
87
|
+
3. **Observe the path.** A symlink or directory there is refused. A file this
|
|
88
|
+
resource does not own is refused without `--adopt`, even one holding the
|
|
89
|
+
pinned bytes: at plan the probe calls it `Unowned`, and at apply, when a
|
|
90
|
+
prop was an Output and the probe never ran, reconcile refuses it the same
|
|
91
|
+
way (`binary-claim.ts`). ⛔ A file with other bytes is refused under
|
|
92
|
+
`--adopt` too, at plan and at apply: adopting it would overwrite it in
|
|
93
|
+
place, and the plan would print that as `adopted`. Under `--adopt`, the
|
|
94
|
+
pinned bytes are kept: no download, and a write only to fix mode or owner.
|
|
95
|
+
⚠️ A rename is not an adoption: declare it with `renamedFrom()`. Under
|
|
96
|
+
`--adopt` the new name takes the file, and the old name's delete leaves a
|
|
97
|
+
path another declaration installed in the same deploy (`adopt-parity.test.ts`).
|
|
98
|
+
4. **Download** `https://github.com/<repo>/releases/download/<tag>/<asset>`,
|
|
99
|
+
into memory, never past the pinned size. Two binaries from one archive at
|
|
100
|
+
once share one download.
|
|
101
|
+
5. **Verify the archive** against its pinned SHA-256, before unpacking a byte.
|
|
102
|
+
6. **Unpack only the declared member**, streaming. The tar reader refuses the
|
|
103
|
+
whole archive for any unsafe entry, declared or not.
|
|
104
|
+
7. **Verify the member** against its own pinned SHA-256.
|
|
105
|
+
8. **Write** through `HostRunner.writeFileAtomic`, the one writer every file
|
|
106
|
+
resource uses: a temp file in the same directory, mode and owner set, then
|
|
107
|
+
rename. Under `sudoRunner`, one staged `install -S`, ⚠️ which sets the mode
|
|
108
|
+
just after its rename (measured: `0600` for under half a millisecond).
|
|
109
|
+
9. **Read back** and compare. A create that does not read back, or whose
|
|
110
|
+
read-back fails, is removed.
|
|
111
|
+
|
|
112
|
+
A binary with the right bytes but the wrong mode or owner is re-written from
|
|
113
|
+
the bytes already on disk, re-hashed, without a download.
|
|
114
|
+
|
|
115
|
+
⛔ A directory respelled to the same place (a symlinked parent, or case on
|
|
116
|
+
APFS) is an `update` that keeps the file, never a `replace`: the replace's
|
|
117
|
+
Phase 2 would delete the old path, which is the file (`launchd/file-identity.ts`).
|
|
118
|
+
|
|
119
|
+
## Trust: why the digests are pinned in code
|
|
120
|
+
|
|
121
|
+
★ **Pinned, not fetched at apply.** A checksum file comes from the same
|
|
122
|
+
account, release and channel as the archive, and every release checked so far
|
|
123
|
+
is mutable (`"immutable": false`). Whoever can swap an archive can swap its
|
|
124
|
+
checksum file, and a provider that trusts what it fetches would call the swap
|
|
125
|
+
verified. A pin moves only through a reviewed change, so a swapped asset is a
|
|
126
|
+
refusal. And a plan knows exactly which bytes it will install without the
|
|
127
|
+
network. Each data-set entry records the checksum file's URL, its own SHA-256
|
|
128
|
+
and the date the pins were copied.
|
|
129
|
+
|
|
130
|
+
⚠️ **The trust root.** At apply it is the pins alone: TLS, GitHub's redirect,
|
|
131
|
+
its CDN or a proxy can make a download fail, never make other bytes pass. The
|
|
132
|
+
pins rest on one read of the GitHub release over TLS on the `recorded` date
|
|
133
|
+
(trust on first use), checked against GitHub's own asset `digest`, then on
|
|
134
|
+
review of that commit and the kit release the stack's lockfile resolves.
|
|
135
|
+
VictoriaMetrics publishes no signature, cosign bundle, SLSA provenance or
|
|
136
|
+
GitHub attestation for these archives: a checksum proves integrity, not
|
|
137
|
+
authorship.
|
|
138
|
+
|
|
139
|
+
★ **What CI proves without a network** (`victoria.test.ts`) is consistency,
|
|
140
|
+
not provenance: each committed checksum file in `src/release/fixtures/victoria/`
|
|
141
|
+
hashes to the digest the data set records for it; the strict parser reads
|
|
142
|
+
them; the data set equals what it reads; every asset is in the committed
|
|
143
|
+
listing, and none is an `-enterprise`, `-cluster` or `vlutils` sibling.
|
|
144
|
+
⚠️ That those recorded digests are GitHub's was measured once, not by CI. A
|
|
145
|
+
change that edits a fixture and its pins together passes; review is the gate.
|
|
146
|
+
|
|
147
|
+
## Refusals
|
|
148
|
+
|
|
149
|
+
Every refusal, by where it is refused (the stack program, the plan, the apply,
|
|
150
|
+
the vendor's bytes, the read-back): [release-binary-refusals.md](./release-binary-refusals.md).
|
|
151
|
+
|
|
152
|
+
Data sets and adding a vendor: [release-binary-catalogs.md](./release-binary-catalogs.md).
|
|
153
|
+
Measurements and limits: [release-binary-measured.md](./release-binary-measured.md).
|
|
154
|
+
What alchemy-run/alchemy would need: [release-binary-upstream.md](./release-binary-upstream.md).
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# Upstream conformance, family by family
|
|
2
|
+
|
|
3
|
+
Status: open ledger. Verified 2026-09-22 by reading `origin/main` `925454b` against
|
|
4
|
+
[provider-standard.md](./provider-standard.md) (`alchemy@2.0.0-beta.79`). Nothing in any
|
|
5
|
+
family was changed by this audit. Each row names the rule it breaks and the upstream
|
|
6
|
+
replacement or fix. A row is crossed off in the PR that fixes it, with that PR's evidence.
|
|
7
|
+
A decision marked **maintainer** is not an agent's to make.
|
|
8
|
+
|
|
9
|
+
## Ranked findings
|
|
10
|
+
|
|
11
|
+
Findings are ranked by what they can break: runtime safety first, then contributability,
|
|
12
|
+
then tidiness.
|
|
13
|
+
|
|
14
|
+
1. **`cloudflare/R2BucketLock` diverges on four rules.**
|
|
15
|
+
- `readLock` ends in `Effect.orDie` (S20).
|
|
16
|
+
- `reconcileLock` and `deleteLock` use `Effect.promise` (S19).
|
|
17
|
+
- `reconcileLock` skips the PUT whenever `output.rules` equals the declaration, without
|
|
18
|
+
reading the live lock (S10). So neither `--force` nor `drift --repair` can restore
|
|
19
|
+
lock rules that were removed out of band. ⚠️ Reasoned from the source, not measured
|
|
20
|
+
live.
|
|
21
|
+
- `accountId` is a prop (S15).
|
|
22
|
+
|
|
23
|
+
It is built on a second Cloudflare SDK, `cloudflare@4.5.0` (S23). Upstream
|
|
24
|
+
`Cloudflare.R2.Bucket` has lifecycle and CORS rules but no lock, so this is a real gap.
|
|
25
|
+
**Fix:** move to `@distilled.cloud/cloudflare/r2` `getBucketLock`/`putBucketLock`,
|
|
26
|
+
observe before the PUT, and resolve the account from `CloudflareEnvironment`. Then drop
|
|
27
|
+
the `cloudflare` peer. That makes it contributable as `lockRules` on `R2.Bucket`, or as
|
|
28
|
+
`Cloudflare.R2.BucketLock`. **Decision:** maintainer, on the upstream shape.
|
|
29
|
+
|
|
30
|
+
2. **`forgejo/*` duplicates an upstream building block.** `client.ts` is a hand-rolled
|
|
31
|
+
Effect `HttpClient` client with a status-carrying `ForgejoError`, while
|
|
32
|
+
`@distilled.cloud/forgejo@1.0.0-rc.12` exists. That package's Smithy service version is
|
|
33
|
+
16.0.3, the version this family measured (S21, S23). Credentials come from
|
|
34
|
+
`FORGEJO_TOKEN` at call time instead of an Auth provider (S24). `read` never answers
|
|
35
|
+
`Unowned` (H1). **Replacement:** the distilled SDK plus an `alchemy/Auth` provider.
|
|
36
|
+
Untyped errors become distilled patches. **Decision:** maintainer, because it changes
|
|
37
|
+
the error surface.
|
|
38
|
+
3. **`cloudflare/MeshNode` duplicates `Cloudflare.Tunnel.WarpConnector`.** The divergence is
|
|
39
|
+
deliberate and documented in [mesh-node.md](./mesh-node.md): upstream persists the
|
|
40
|
+
connector token in state as a `Redacted` attribute, and it cannot create an HA node. The
|
|
41
|
+
type string `Cloudflare.MeshNode` sits inside upstream's own namespace (H14).
|
|
42
|
+
**Options:** contribute `ha` upstream and raise the token-in-state concern, then switch;
|
|
43
|
+
or keep the house resource as a recorded divergence. **Decision:** maintainer.
|
|
44
|
+
4. **Unguarded `Bun.*`, and the `node:*` modules upstream names, in shipped provider code**
|
|
45
|
+
(S42). This is listed in [the Bun line](#the-bun-line). The exported `Bun.*` paths crash
|
|
46
|
+
under Node, which is the runtime this package promises its consumers. The `node:*` paths
|
|
47
|
+
load on Node but break the Effect-only rule.
|
|
48
|
+
5. **`launchd`, `linux` and `caddy` have promise-based seams (H3).** `HostRunner` and
|
|
49
|
+
`CaddyAdmin` are plain `async` interfaces, with `node:child_process`, `node:fs/promises`
|
|
50
|
+
and `node:http` behind them, and failures are untyped `Error` (S19, S21). The stated
|
|
51
|
+
reason is that a consumer implements plain async functions. Upstream meets the same need
|
|
52
|
+
by letting a caller provide `FileSystem`, `ChildProcessSpawner` or `CommandExecutor`
|
|
53
|
+
layers, and `alchemy/Util/AtomicFile.writeFileAtomic` covers the temp-and-rename write.
|
|
54
|
+
⚠️ Only in part. `local-runner.ts` creates its temp file with `O_EXCL`, mode 0600 and
|
|
55
|
+
`chown` before `chmod`, and it refuses a planted symlink. `writeFileAtomic` writes with
|
|
56
|
+
default flags and mode, `chmod`s afterwards, and never `chown`s.
|
|
57
|
+
**Decision:** maintainer. These families are also host-specific, so "house-only" is a
|
|
58
|
+
valid answer.
|
|
59
|
+
6. **`openbao/*` has no upstream equivalent, and it conforms on the contract** (Effect
|
|
60
|
+
`HttpClient`, strict `Unowned`, retain). It diverges in three ways:
|
|
61
|
+
- 30 `Effect.die` sites in non-test source, several of them on `diff` and
|
|
62
|
+
`reconcile` paths (S20);
|
|
63
|
+
- 42 test files on `node:test` (S43);
|
|
64
|
+
- `Bao.*` type strings, where the vendor name is `OpenBao` (H14).
|
|
65
|
+
|
|
66
|
+
No distilled SDK exists. OpenBao serves an OpenAPI document, so a generated SDK in
|
|
67
|
+
distilled's shape is the S23 path. **Decision:** maintainer, on whether the kit authors
|
|
68
|
+
a distilled package.
|
|
69
|
+
|
|
70
|
+
7. **`proxmox/*` is the model for S38.** Its types and constraints are generated from the
|
|
71
|
+
vendor schema, and a manifest records the version and sha256. It diverges in four ways:
|
|
72
|
+
- the generic factory's `read` returns plain attributes, so every family except `Lxc`
|
|
73
|
+
adopts silently, against the house's own H1;
|
|
74
|
+
- 27 `Effect.die` sites in non-test source (S20);
|
|
75
|
+
- 7 test files on `node:test`;
|
|
76
|
+
- `Pbs.*` type strings (H14).
|
|
77
|
+
8. **`talos/*`.** Its `talosctl` calls go through `ChildProcessSpawner`, as upstream's Docker
|
|
78
|
+
provider does, which conforms. Its file handling uses `Bun.file`, `Bun.write`,
|
|
79
|
+
`Bun.YAML` and `node:fs` (S42), and it uses `Effect.promise` in 3 files (S19). The
|
|
80
|
+
kubeconfig it writes is meant to be consumed through upstream's
|
|
81
|
+
`Kubernetes.KubeConfig({ path })`.
|
|
82
|
+
9. **`netbox/*` conforms mostly.** Its constraints are generated from NetBox 4.7.0's
|
|
83
|
+
OpenAPI, and it uses Effect `HttpClient`. Adopting first is documented as deliberate.
|
|
84
|
+
It diverges in its credentials: `NETBOX_TOKEN` is read at call time (S24).
|
|
85
|
+
10. **Every family repeats `list: () => Effect.succeed([])`**, 30 times, and only
|
|
86
|
+
`R2BucketLock` and `MeshNode` declare `nuke`. The constructor already defaults `list`
|
|
87
|
+
(S12). **Fix:** write the reason where it differs, and declare `nuke: { skip: true }`
|
|
88
|
+
where nuke must never reach the object.
|
|
89
|
+
11. **Resource JSDoc is not in upstream's generator format.** Zero files use `@resource`,
|
|
90
|
+
`**Example:**` or `### Section` (S31, H10). **Decision:** maintainer, because it moves
|
|
91
|
+
the house's glyph rationale into `//` comments.
|
|
92
|
+
12. **Tests never use `alchemy/Test/Bun`.** Every lifecycle is proven against loopback
|
|
93
|
+
fakes (S28, H12). Live suites need a place to run, and that is a maintainer decision.
|
|
94
|
+
|
|
95
|
+
## Conforms
|
|
96
|
+
|
|
97
|
+
- **`github/declareRepoPolicy`** composes upstream `GitHub.Repository` and
|
|
98
|
+
`GitHub.Ruleset`. Its one hazard is upstream's own: `Ruleset.read` returns `undefined`
|
|
99
|
+
without prior output, so a first deploy creates a duplicate
|
|
100
|
+
([repo-policy.md](./repo-policy.md)). The fix belongs upstream: read by name and answer
|
|
101
|
+
`Unowned`.
|
|
102
|
+
- **`cloudflare/website.ts`** sets house defaults over upstream `Website.Astro` and
|
|
103
|
+
`Website.Vite`.
|
|
104
|
+
- **`ownership/*`** is built only on `AdoptPolicy`, `Stack`, `State` and `Artifacts`. Its
|
|
105
|
+
apply-time re-check (H2) exists because beta.79 skips the adoption probe while props hold
|
|
106
|
+
an Output. That fix also belongs in the engine upstream.
|
|
107
|
+
- **`verify/*`** (`hf-adopt-verify`) runs Alchemy's own planner. It exists because beta.79
|
|
108
|
+
labels every cold adoption as an update (H6).
|
|
109
|
+
|
|
110
|
+
## The Bun line
|
|
111
|
+
|
|
112
|
+
These are non-test files under `src/` that the standard (S42) says must be portable. "Exp"
|
|
113
|
+
marks files exported from a subpath `index.ts`. `git grep` on `origin/main` finds 23 non-test
|
|
114
|
+
files under `src/` that call `Bun.*` or import `node:*`/`bun:*`. Six of them are outside
|
|
115
|
+
S42's scope:
|
|
116
|
+
|
|
117
|
+
- four loopback fakes (`caddy/fake-caddy.ts`, `openbao/fake-bao.ts`, `proxmox/fake-pve.ts`,
|
|
118
|
+
`proxmox/fake-pve-lxc.ts`), which are test-side under S44;
|
|
119
|
+
- `proxmox/provision-cli-fake.ts`, which is used only by tests;
|
|
120
|
+
- `verify/args.ts`, which belongs to the CLI and so is tooling under S43.
|
|
121
|
+
|
|
122
|
+
That leaves 17 files. The table lists them, plus `provision-cli-fake.ts`, which should move
|
|
123
|
+
out of `src/`. The six out-of-scope files still ship in the tarball's `src/`, but no export
|
|
124
|
+
reaches them.
|
|
125
|
+
⚠️ The first version of this table said 16 files and left out `launchd/job-form.ts`.
|
|
126
|
+
⚠️ Corrected 2026-09-22: 14 of the 17 break upstream's rule. `launchd/job-form.ts`,
|
|
127
|
+
`proxmox/write-only.ts` and `proxmox/pbs-notification-target-wire.ts` use only synchronous
|
|
128
|
+
`node:crypto` or `Buffer`. Upstream allows those inside `Effect.sync`
|
|
129
|
+
(`AGENTS.md@v2.0.0-beta.79#Workflow`), and 40 upstream `src/` files import `node:crypto`,
|
|
130
|
+
among them `Fly/Secret.ts` and `Railway/Variable.ts`. They were listed under a blanket
|
|
131
|
+
`node:*` ban that upstream does not have.
|
|
132
|
+
|
|
133
|
+
| file | API | exp | portable replacement |
|
|
134
|
+
| ---------------------------------------------------------- | -------------------------------------------- | --- | -------------------------------------------- |
|
|
135
|
+
| `openbao/cloudflare-roles-config.ts` | `Bun.YAML` | yes | parse on the tooling side; pass data in |
|
|
136
|
+
| `openbao/digest.ts` (14 importers) | `Bun.CryptoHasher` | — | `Crypto.digest` + hex; keep `canonical()` ⚠️ |
|
|
137
|
+
| `openbao/cloudflare-parity-snapshot.ts` | `Bun.file`, `Bun.Glob` | — | `FileSystem` + `Path` |
|
|
138
|
+
| `openbao/approle-login-form.ts` | `Bun.file` | — | `FileSystem.readFileString` |
|
|
139
|
+
| `openbao/approle-login-result.ts` | `Bun.inspect` | — | a plain formatter |
|
|
140
|
+
| `openbao/forgejo-bootstrap.ts` | `Bun.spawn` | — | unreferenced; `ChildProcessSpawner` or drop |
|
|
141
|
+
| `talos/credentials.ts` | `Bun.write/file/env/randomUUIDv7`, `node:fs` | — | `FileSystem.makeTempFileScoped` |
|
|
142
|
+
| `talos/kubeconfig.ts` | `Bun.file` | yes | `FileSystem` |
|
|
143
|
+
| `talos/talos-machine-config.ts` | `Bun.file` | yes | `FileSystem` |
|
|
144
|
+
| `talos/values.ts` | `Bun.YAML`, `node:crypto` | — | tooling-side parse; hash may stay |
|
|
145
|
+
| `launchd/local-runner.ts` | `node:child_process`, `node:fs/promises` | yes | H3 above |
|
|
146
|
+
| `launchd/sudo-stage.ts` | `node:fs/promises`, `node:os`, `node:path` | — | `FileSystem`, `Path` |
|
|
147
|
+
| `launchd/job-form.ts` | `node:crypto` (`createHash`) | — | allowed in `Effect.sync` |
|
|
148
|
+
| `linux/ssh-runner.ts` | `node:child_process`, `node:crypto` | yes | H3 above |
|
|
149
|
+
| `caddy/local-admin.ts` | `node:http` (unix socket) | yes | H3 above |
|
|
150
|
+
| `proxmox/write-only.ts`, `pbs-notification-target-wire.ts` | `node:crypto`, `node:buffer` | — | allowed in `Effect.sync` ⚠️ |
|
|
151
|
+
| `proxmox/provision-cli-fake.ts` | `Bun.spawn`, `node:fs` | — | test-only; move out of `src/` |
|
|
152
|
+
|
|
153
|
+
The ⚠️ rows:
|
|
154
|
+
|
|
155
|
+
- `openbao/digest.ts`: every `Bao.*` family persists this digest in state, so a swap must
|
|
156
|
+
hash the same bytes (`canonical()`, lowercase hex), or every row plans an update.
|
|
157
|
+
`Crypto.digest` from `effect/Crypto` is in every stack's services and fails with a typed
|
|
158
|
+
error; `alchemy/Util/sha256` wraps WebCrypto in `Effect.promise`.
|
|
159
|
+
- `proxmox/write-only.ts`: a salted scrypt seal with `timingSafeEqual`. No Alchemy helper
|
|
160
|
+
replaces it. A plain `sha256` would make a write-only secret cheap to brute-force from
|
|
161
|
+
state, and it would change the persisted seal format.
|
|
162
|
+
|
|
163
|
+
Test runners: 51 files import `node:test`/`node:assert` (openbao 42, proxmox 7, forgejo 1,
|
|
164
|
+
talos 1), and 126 import `bun:test`. S43 says `bun:test`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homeflare/alchemy",
|
|
3
|
-
"version": "0.
|
|
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
|
+
});
|